Skip to content

Commit 3195c71

Browse files
committed
docs(plan): stop journal recovery from overwriting other people's edits
Round 3 confirmed the Bun.TOML measurements independently and accepted the restricted encoder, then found the recovery algorithm destructive. The rule was 'if either target differs from the post-image, rewrite both'. Crash after the first write, let the user or Codex edit config.toml, and recovery overwrites their edit with a stale post-image. Recovery now classifies each target against both recorded hashes and refuses to write any file matching neither - that file belongs to someone else. One unrecognised target aborts the whole recovery with recovery_required rather than rolling forward into a state nobody intended. The commit point was also mixing two models: journal-rename-is-commit alongside a rollback that claimed a failed request changes nothing. The journal is now prepared intent, commit is both targets matching the post-image, and rollback is the only failure path. Durability adds parent-directory fsync with a documented Windows fallback. Stale-lock breaking could delete a successor's lock and admit two writers. Takeover is now an atomic rename to a token-quarantined name that exactly one contender can win, and release deletes only a lock whose token still matches. Adoption imported the raw TOML source line as the body, so a value containing an escaped newline would have become twelve literal characters. It now decodes through the inverse of our own encoder, accepting only the three escapes we emit, and previews both the source line and the decoded body. Malformed marker-owned lines get the same flow instead of being a permanent lockout, and store-missing repair is described as what it is: salvage of one concatenated string, with the losses listed and a backup written first. Character validation is defined over Unicode scalar values, rejects unpaired surrogates and C1 controls, measures caps in UTF-8 bytes after normalization, and reports code-point positions. One golden fixture parsed by real toml_edit now backs the claim that byte equality means Rust reads what we intended.
1 parent 921070b commit 3195c71

4 files changed

Lines changed: 203 additions & 58 deletions

File tree

devlog/_plan/260802_codex_set_prompt_composer/000_plan.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,10 @@ own developer-role section ahead of world-state content
7070
(`config_toml.rs:216`, `session/mod.rs:3413`). So:
7171

7272
- **Custom layers compose into `developer_instructions`**, concatenated in row
73-
order, each wrapped in a marker comment carrying its row id.
73+
order. Layer identity lives in `$CODEX_HOME/opencodex-prompt.json`, which
74+
opencodex owns; config.toml receives only a generated projection of the
75+
enabled subset. `010` §Storage explains why an earlier in-TOML marker scheme
76+
was abandoned.
7477
- **`model_instructions_file` is not written by the `+` flow at all.** It
7578
appears in the Prompt section only as a read-only *status* row that reports
7679
whether something outside opencodex has replaced the base prompt.

devlog/_plan/260802_codex_set_prompt_composer/005_ux_design.md

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -135,8 +135,9 @@ Three places where the UI must resist claiming more than it knows:
135135

136136
1. **Timing.** "새 세션부터 적용됩니다" (`003` §3). Never "즉시 적용", never
137137
"재시작 필요" — neither is proven.
138-
2. **Override.** A managed layer can beat our write (`003` §6). When configured
139-
and effective disagree, say so instead of showing a toggle that looks applied.
138+
2. **Scope of what we read.** opencodex reads one of the eight config layers in
139+
`003` §1, so the panel says "이 파일의 설정" and never claims the running
140+
Codex agrees. Managed-override detection is deferred, not approximated.
140141
3. **Completeness.** Third-party extensions can add layers we cannot enumerate
141142
(`001` needs-verification). The list is labelled as the layers opencodex
142143
knows about, not as every layer that exists.
@@ -152,9 +153,15 @@ Three places where the UI must resist claiming more than it knows:
152153
`003` §4 — Codex cannot parse malformed TOML either, so writing would compound
153154
the failure rather than recover from it.
154155
- **`developer_instructions` exists without our marker:** built-in toggles keep
155-
working; custom layers are unavailable, with a plain explanation that the key
156-
is externally managed and the one-line manual step to hand it over. `010`
157-
§Ownership explains why refusing beats guessing.
156+
working; the custom group offers **Adopt**, which previews the original line
157+
and the exact decoded body, offers a copy, and imports it only on
158+
confirmation (`010` §Ownership).
159+
- **Our line exists but is malformed:** `drift: "owned-malformed"`, handled the
160+
same way — preview, copy, confirmed re-adopt or replace. Reformatting a line
161+
we generated must never lock a user out.
162+
- **Store lost with a live projection:** `drift: "store-missing"`. Writes are
163+
refused until the user salvages the projected text as one layer, with the
164+
losses listed and a backup written first.
158165
- **No custom layers:** the CUSTOM group shows a one-line invitation, not an
159166
empty box.
160167

devlog/_plan/260802_codex_set_prompt_composer/010_wp1_prompt_layers_core.md

Lines changed: 174 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,12 @@ export type WriteResult =
6767

6868
export type WriteError =
6969
| "config_unreadable" | "stale_revision" | "developer_instructions_not_owned"
70-
| "unknown_layer" | "reparse_failed" | "store_unreadable";
70+
| "unknown_layer" | "store_unreadable" | "invalid_characters"
71+
| "adopt_unsupported_form" | "write_superseded" | "recovery_required";
72+
73+
export type Drift =
74+
| "journal-present" | "projection-stale" | "store-missing"
75+
| "owned-malformed" | null;
7176

7277
export function readPromptLayers(opts?: Paths): PromptLayerSnapshot;
7378
export function setToggle(id: ToggleId, enabled: boolean, revision: string, opts?: Paths): WriteResult;
@@ -176,8 +181,9 @@ it on four counts, all correct:
176181
**`$CODEX_HOME/opencodex-prompt.json` is the single source of truth** for custom
177182
layers — every layer, enabled or not, with body, title, and order.
178183

179-
`config.toml` receives exactly one generated root key, written through a real
180-
TOML serializer:
184+
`config.toml` receives exactly one generated root key, emitted by our own
185+
three-rule encoder over a restricted character set (§Why no prompt text goes
186+
into config.toml at all):
181187

182188
```toml
183189
# Auto-injected by opencodex
@@ -189,9 +195,10 @@ Consequences that dissolve three blockers at once:
189195
- **No fences.** Bodies are joined with `\n\n` and never carry structure that
190196
has to survive a round trip through TOML. The value is write-only from our
191197
side; we never parse it back to recover layer identity.
192-
- **Encoding is a solved problem.** We emit through a serializer that escapes
193-
`"`, `\`, and control characters, and we assert the result re-parses. No body
194-
can produce malformed TOML.
198+
- **Encoding is total.** Three escapes — `"`, `\`, newline — over a character
199+
set that excludes everything ambiguous. Verification is a byte comparison, not
200+
a reparse, because no TOML parser we could run is trustworthy enough to be the
201+
judge (measured defects in `Bun.TOML` are recorded below).
195202
- **Reconciliation disappears.** There is exactly one authority. `config.toml`
196203
is a *projection*, never a source.
197204

@@ -249,20 +256,42 @@ control.
249256

250257
**Resolution: restrict the writable body character set.**
251258

252-
A body is accepted only if it consists of printable Unicode, spaces, `\n`, and
253-
`\t`. Tabs are normalized to four spaces at save time — a prompt loses nothing,
254-
and the transposition defect disappears with the character. Everything else is
255-
rejected at the API with a precise message.
259+
"Printable Unicode" is not executable, as the audit noted. The rule is defined
260+
over **Unicode scalar values**, not UTF-16 code units:
256261

257-
Within that set, TOML basic-string encoding is trivially total: escape `"` and
258-
`\`, emit `\n` for newline, pass every other character through. Three escapes,
259-
all unambiguous, none in the defective set. CRLF is normalized to LF on the way
260-
in, so `\r` never appears.
262+
| Input | Handling |
263+
|---|---|
264+
| U+0009 tab | normalized to four spaces |
265+
| CRLF, lone CR | normalized to LF |
266+
| U+000A newline | accepted, encoded as `\n` |
267+
| other C0 controls, U+007F DEL | **rejected** with position |
268+
| C1 controls U+0080–U+009F | **rejected** — invisible and rarely intentional |
269+
| **unpaired surrogate** | **rejected** — not a scalar value; UTF-8 encoding would substitute U+FFFD and silently alter the prompt |
270+
| U+2028, U+2029 | accepted; they are not TOML line terminators and cannot end a basic string |
271+
| everything else, incl. non-BMP | accepted verbatim |
272+
273+
Validation iterates code points, so positions are reported as **code-point
274+
indices** consistently across the API, the linter, and the editor. Size caps are
275+
measured in **UTF-8 bytes after normalization** — tab expansion can grow a body,
276+
so checking before normalization would let an oversized value through.
277+
278+
Within that set, TOML basic-string encoding is total: escape `"` and `\`, emit
279+
`\n` for newline, pass everything else through. Three escapes, all unambiguous,
280+
none in the defective set. `\r` never appears because CRLF is normalized away.
261281

262282
Verification is then a **byte-level** assertion rather than a semantic one: the
263283
emitted line must equal `key = "` + escaped + `"`, and re-reading the file must
264-
yield that exact line. No TOML parser is involved on the write path, which means
265-
no parser's defects can hide a divergence.
284+
yield that exact line. No TOML parser is involved on the write path, so no
285+
parser's defects can hide a divergence.
286+
287+
**Byte equality is not the same as Rust agreeing with us**, which the audit
288+
rightly pressed on. A hand-written grammar matcher tests the encoder against the
289+
encoder's own assumptions. So WP1 also carries **one** independent proof: a
290+
checked-in fixture of generated lines covering every accepted character class,
291+
parsed by the real `toml_edit` through a tiny Rust test in the pinned upstream
292+
checkout, with the decoded values committed as a golden file. It runs on demand,
293+
not in the GUI suite, and it is the only thing that settles what Codex actually
294+
reads.
266295

267296
This costs the user the ability to put a NUL or a bell character in a prompt.
268297
That is not a real loss, and it buys a guarantee that a dependency plus a
@@ -308,23 +337,43 @@ your existing instructions by hand. That is not a feature.
308337

309338
**Takeover (`POST /api/codex-prompt/adopt`):**
310339

311-
1. read the raw source line, verbatim, without decoding it
312-
2. show it to the user exactly as it appears in the file, alongside a plain
313-
statement of what adoption will do
314-
3. on explicit confirmation, and only then:
315-
- write the raw text into `opencodex-prompt.json` as one custom layer titled
316-
"Imported from config.toml", enabled
317-
- replace the original two lines with the canonical owned form, through the
318-
journal transaction every other write uses
319-
4. offer a copy button first, so the user can keep a copy outside opencodex
340+
1. read the raw source line
341+
2. **decode it** with the inverse of our own encoder (below) — an earlier draft
342+
stored the raw line as the body, which an audit correctly called a semantics
343+
change: `"hello\nworld"` would have been imported as those twelve literal
344+
characters rather than two lines
345+
3. show the user **both** the original source line and the exact decoded body
346+
that will be imported, plus a copy button
347+
4. on explicit confirmation: write the decoded body into
348+
`opencodex-prompt.json` as one enabled layer titled "Imported from
349+
config.toml", and replace the original lines with the canonical owned form,
350+
through the journal transaction every other write uses
351+
352+
**The decoder is deliberately narrow.** It accepts a single-line basic string
353+
containing only `\"`, `\\`, and `\n` — precisely the three escapes our encoder
354+
emits. Anything else is refused:
355+
356+
| Input | Result |
357+
|---|---|
358+
| `\t`, `\f`, `\b`, `\r` | refused — `Bun.TOML` transposes two of these, so we will not guess |
359+
| `\uXXXX` | refused — decoding it correctly is exactly the ambiguity we removed |
360+
| multi-line basic or literal string | refused |
361+
| unterminated or unbalanced quotes | refused |
362+
363+
Refusal is `adopt_unsupported_form` with the file path and line number. That is
364+
a narrow dead end that names where to look, and it is honest about the reason:
365+
we do not have a parser we trust for the general case.
366+
367+
### Malformed owned lines
320368

321-
If the existing value cannot be read as a single-line basic string — the only
322-
form we can extract without a trustworthy parser — adoption is refused with the
323-
file path and line number, and the user is told to move the text manually. That
324-
is a narrower dead end than before, and it names exactly where to look.
369+
Marker present, shape non-canonical. The audit flagged this as a new dead end,
370+
and it was — Adopt covered only the marker-absent case, and Repair only drift.
325371

326-
Nothing is deleted without confirmation, and the original text is shown before
327-
anything is changed.
372+
It is now its own state, `drift: "owned-malformed"`, with the same treatment as
373+
Adopt: show the raw line, offer a copy, and on confirmation either re-adopt it
374+
through the narrow decoder or replace it outright with an empty owned line if
375+
the user prefers. A user cannot be locked out by reformatting a line we
376+
generated.
328377

329378
### Revision — hashes the edit base, not a summary of it
330379

@@ -380,18 +429,64 @@ projection first means that if step 5 fails, the recorded pre-image restores
380429
config.toml and the source of truth was never touched — a failed request leaves
381430
*nothing* changed, which is the semantics blocker 2 demanded.
382431

383-
**Recovery — at service start and at lock acquisition, never in a GET:**
432+
**Recovery — at service start and at lock acquisition, never in a GET.**
433+
434+
An earlier draft said "if either target differs from the post-image, rewrite
435+
both from the post-image". An audit found that destroys legitimate work: crash
436+
after writing config.toml, user or Codex then edits config.toml, recovery sees a
437+
mismatch and overwrites their edit with a stale post-image.
438+
439+
Recovery therefore classifies **each target independently** against both
440+
recorded hashes, and never writes a file it does not recognise:
441+
442+
| Target matches | Meaning | Action |
443+
|---|---|---|
444+
| post-image | already applied | leave it |
445+
| pre-image | not yet applied | roll forward from post-image |
446+
| **neither** | **externally modified** | **stop; `recovery_required`** |
447+
448+
A single unrecognised target aborts the whole recovery. We do not roll forward
449+
one file while another carries a stranger's edit — that would produce a state
450+
neither the user nor the journal ever intended.
451+
452+
`recovery_required` names both paths and the journal, and blocks mutations until
453+
the user resolves it. An honest stop beats best-effort repair on a file the user
454+
also edits by hand.
455+
456+
If the journal itself is truncated or unparseable: restore from the pre-image
457+
**only if every target still matches its recorded pre-image or post-image
458+
hash**; otherwise touch nothing and report `recovery_required`.
384459

385-
- journal present, both targets already match its post-image → delete it, done
386-
- journal present, either target does not match → rewrite both from the
387-
post-image, then delete
388-
- journal present but itself truncated or unparseable → restore both from the
389-
pre-image if it is intact; otherwise leave every file untouched and report
390-
`recovery_required`
460+
### Commit point — one state machine, not two
391461

392-
If rollback itself fails, nothing further is attempted and `recovery_required`
393-
is returned with both paths named. Silent best-effort repair on a file the user
394-
also edits is worse than an honest stop.
462+
The same audit found the draft mixing two models: journal-rename-is-commit
463+
(implying roll-forward) alongside "a step-5 failure restores the pre-image"
464+
(implying the journal is only intent). Both cannot hold.
465+
466+
**Chosen: the journal is prepared intent. Commit is when both targets match the
467+
post-image.**
468+
469+
- Failure before both targets are written → roll **back** to the pre-image, and
470+
the API reports a plain error. Nothing changed.
471+
- Failure after both match → the write succeeded; recovery only deletes the
472+
journal.
473+
- The unrecognised-target case above overrides everything and stops.
474+
475+
This is the model that makes "a failed request changes nothing" true, which is
476+
what audit blocker 2 required. Roll-forward-after-journal would have made a
477+
failed request silently succeed later.
478+
479+
### Durability
480+
481+
`fsync` on a temp file does not make its directory entry durable. Each step is:
482+
write temp → `fsync` file → rename → **`fsync` the parent directory**. Journal
483+
deletion is also followed by a directory `fsync` before completion is reported.
484+
485+
On Windows, directory `fsync` is unavailable; `FlushFileBuffers` on the file plus
486+
`MoveFileEx` with write-through is the documented fallback, and the
487+
`030`-era Windows CI job covers it. Where neither is available, the journal is
488+
still written first, so the worst case is a recovery that reports
489+
`recovery_required` rather than one that loses data silently.
395490

396491
### Reads never write
397492

@@ -418,9 +513,19 @@ Three distinct states, distinguished before anything is written:
418513
| absent | **present and non-empty** | **store lost** | refuse writes, `drift: "store-missing"`, offer Repair |
419514
| present | either | normal | normal |
420515

421-
Repair from `store-missing` reconstructs a single custom layer from the projected
422-
text, presented to the user for confirmation before it is saved. The text is
423-
still in config.toml; it is recoverable, and it is not discarded silently.
516+
Repair from `store-missing` is **salvage, not reconstruction** — the audit was
517+
right that the earlier wording oversold it.
518+
519+
The projection holds one concatenated string of the enabled layers. It cannot
520+
recover layer boundaries, ids, titles, row order, disabled layers, or their
521+
bodies, and it cannot tell a `\n\n` that separated two layers from one that a
522+
user typed. All of that is gone with the store.
523+
524+
So salvage takes the whole projected text and offers it as **one new layer**,
525+
with the exact body previewed and the losses listed explicitly before the user
526+
confirms. The pre-salvage bytes of config.toml are copied to
527+
`opencodex-prompt.salvage-<timestamp>.txt` first, so even a mistaken
528+
confirmation is recoverable.
424529

425530
### Cross-process locking
426531

@@ -429,8 +534,24 @@ one service. A CLI invocation, a second service, or Codex itself can all write
429534
the same file.
430535

431536
`$CODEX_HOME/opencodex-prompt.lock` is an advisory lock created with `wx`,
432-
carrying pid and start time. A lock whose pid is gone is stale and may be broken
433-
after 10 seconds. Every opencodex writer — service, CLI, route — takes it.
537+
carrying a random **token**, pid, and start time. Every opencodex writer —
538+
service, CLI, route — takes it.
539+
540+
Naive stale-breaking is racy, as the audit showed: A judges the lock stale, B
541+
removes it and acquires its own, A then deletes *B's live lock* and both
542+
proceed. Unlinking a path you did not verify is the bug.
543+
544+
**Race-safe takeover:**
545+
546+
1. read the lock; if its pid is live or it is younger than 10s, wait
547+
2. otherwise `rename()` it to `opencodex-prompt.lock.stale-<our token>` — an
548+
atomic operation exactly one contender can win
549+
3. the winner creates the real lock with `wx` and deletes the quarantined file
550+
4. a loser's rename fails with `ENOENT`; it returns to step 1
551+
552+
**Release deletes only a lock whose token still matches ours.** A token mismatch
553+
means we were superseded: we do not delete, and we report `write_superseded`
554+
rather than assuming the file is still ours.
434555

435556
That covers opencodex against itself. **It does not cover Codex**, which knows
436557
nothing about our lock. The residual window is between step 2's read and step 4's
@@ -478,6 +599,13 @@ Every case takes explicit temp paths (`004` §H: never resolve the real
478599
expected two lines** — the assertion that replaces reparse verification
479600
21. **a golden fixture of the emitted line is checked against the TOML spec's
480601
basic-string grammar by hand-written matcher**, not by `Bun.TOML`
602+
21a. unpaired high surrogate → rejected with code-point position
603+
21b. unpaired low surrogate → rejected
604+
21c. U+007F DEL and a C1 control (U+0085) → rejected
605+
21d. U+2028 / U+2029 accepted and pass through unescaped
606+
21e. size caps measured in UTF-8 bytes **after** tab expansion
607+
21f. **golden Rust fixture**: generated lines for every accepted character class
608+
parsed by real `toml_edit`, decoded values matching a committed golden file
481609

482610
Cases 20-21 exist because `Bun.TOML.parse` on Bun 1.3.14 transposes `\t`/`\f`
483611
and rejects `\u0007` (measured; see §Why no prompt text goes into config.toml).

0 commit comments

Comments
 (0)