@@ -67,7 +67,12 @@ export type WriteResult =
6767
6868export 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
7277export function readPromptLayers(opts ? : Paths ): PromptLayerSnapshot ;
7378export 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
177182layers — 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
262282Verification is then a ** byte-level** assertion rather than a semantic one: the
263283emitted 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
267296This costs the user the ability to put a NUL or a bell character in a prompt.
268297That 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
380429config.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
429534the 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
435556That covers opencodex against itself. ** It does not cover Codex** , which knows
436557nothing 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
47960021 . ** 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
482610Cases 20-21 exist because ` Bun.TOML.parse ` on Bun 1.3.14 transposes ` \t ` /` \f `
483611and rejects ` \u0007 ` (measured; see §Why no prompt text goes into config.toml).
0 commit comments