feat(codex): a write substrate that survives being interrupted - #998
Merged
Conversation
…not the prior plan ocx restore already restores native Codex WITHOUT stopping the proxy (src/cli/help.ts:18), and POST /api/stop restores before it drains (management-api.ts:181). So the Codex half of this unit needs no durable operation-state engine; both directions already exist as CLI verbs. Records the three real asymmetries (enable is syncModelsToCodex not bare inject; a post-injection root model selection is destroyed; resume history is reversible but not byte-identical), and the defect that matters for the GUI: restoreNativeCodex() collapses a structured history failure into a message string while keeping success:true, so a card trusting that boolean would report a clean disable while routed threads stay hidden. Names the hazard that replaces the rollback engine: no persisted per-client desired state, so startup/ensure/sync/api-sync can silently re-inject.
… documented contract The prior unit concluded Desktop removal was impossible. It conflated two requirements: restoring the exact prior selection (still impossible, we never recorded appliedId) and returning the user to standard Claude (a documented behavior we can aim at). Anthropic's configuration reference states third-party mode activates only when inferenceProvider and its credentials are valid; otherwise Desktop launches in standard mode. So we point appliedId at a present, readable, credential-free config with no inferenceProvider instead of guessing what an absent or dangling appliedId does — all four unproven behaviors are designed around rather than relied on. Also kills the 'just pick Default' shortcut with local evidence: this machine's _meta.json has a Default entry whose <id>.json does not exist. And settles the tested hypothesis: 'ocx claude desktop default' sets a model-family default inside our own profile and never touches appliedId, so it is not the restore verb its name suggests.
… to fix 003 — a switch has no memory. Only Claude Code has a durable desired state; everything else reads observed disk artifacts, and the six-client ownership record is DELETED by disable, so it structurally cannot carry an OFF. The shipped Grok toggle already fails this way: OFF persists nothing and every ocx start / ensure / api-grok-apply rewrites the fence. Proposes a default-ON OcxConfig.clientIntegrations map, and names the paths that must NOT be gated — crash-journal repair, ownership checks, owned teardown, and shared transports, since disabling an integration means stop writing that client's config, never stop serving. 004 — one rejected modality value poisons a whole client config. gjc rejects audio on zenmux/meta-muse-spark-1.1 and falls back to its built-in list. Pi carries the identical bug (upstream schema is text|image and it returns an EMPTY config on failure), unobserved only because its file is empty here. Same class as the Codex 'video' incident that showed zero apps. Fix belongs at the client-dialect boundary, not in ExportModel or normalizeExportModels.
…back engine The operation-state engine (010, never written) is dropped: research 001-003 shows Codex restore already works with the proxy up, Desktop disable has a documented standard-mode target, and the actual missing piece is a switch that remembers being off. New phase map, dependency-ordered: modality filter (independent, fixes a live failure), desired-state schema (the foundation both toggles consume and the fix for the shipped Grok regression), API keys row, then Codex and Desktop as parallel siblings. 010 is the first phase written to diff level: one client-dialect modality helper called from the Pi and Gajae builders only, with the whole-catalog assertion that would actually have caught the bug the per-entry tests missed.
…etire the superseded two 020 desired-state, 030 api-keys row, 040 codex toggle, 050 desktop toggle — each a copy-paste-executable design against the current tree, with real diffs, test plans naming specific files and cases, and live-proof verification rather than a green suite. The pre-split 020_codex_toggle and 030_desktop_toggle move to _retired/ with a note on which premise failed: Codex did not need a captured pre-state because ocx restore already exists, and Desktop's removal was judged impossible only by conflating exact-prior-selection restore with return-to-standard-mode.
…g the one I flagged myself FAIL, 7 High. Five of them are one defect: I designed a flag and called it a state machine. Dropping the operation-state engine was right for its rollback half and wrong for its coordination half — single-flight, ordering, restart reconciliation — which I removed without noticing it was load-bearing for a different reason. Sharpest form: persist OFF, crash before the remover runs, and restart only skips future writes, so desired OFF and observed ON never reconcile. My claim in 003 that the boolean survives a restart was true of the boolean and false of the system. Also accepts #1, the decision I had flagged as most likely to matter: the invariant 'disabling must not stop serving' is correct for installation state and wrong for ingress admission. claudeCode.enabled is the documented kill switch for /v1/messages; removing it would silently reopen an ingress for a user whose switch still reads OFF. Keep both gates, drive them through the new helper. And #5, my dispatch error: WP5 and WP6 were written in parallel against the same route and do not compose — WP6 re-types the union without the Codex entry WP5 adds. WP3 takes the shared contract; they become sequential. Replacement is smaller than the old engine: mutatePersistedConfig (already in the tree at config.ts:1854, which I failed to look for), per-client single-flight with a re-read before the write, and startup convergence.
…ailing phases WP3 keeps the Claude ingress and discovery gates instead of deleting them, now driven by clientIntegrationEnabled; swaps whole-object saving for field-scoped mutatePersistedConfig; gives all six file clients a writer so the opencode guard is actually reachable; adds per-client single-flight with a persisted re-read immediately before every irreversible write; makes desired OFF a converge instruction that startup re-runs; and takes ownership of the four-client contract WP5 and WP6 both consume. WP5 pulls the CLI back into scope, because WP3's gate changes what ocx restore and restore back do: restore persists OFF, restore back persists ON, and a skipped sync is a discriminated result rather than a bare ok:true that would print success while doing nothing. WP6 stops its status read from creating a Desktop library on a machine that never had Desktop, derives observed state from the selected profile's real contents rather than our own fingerprint, and lands after WP5.
…not the fixes Five closed, six still open, six NEW including three High. A converging audit closes more than it opens; this one did the opposite, and one new finding is that my fix for 'startup must converge' can tear down another installed service's Codex/Grok state from a different OPENCODEX_HOME, because the reconciliation registry calls the removers without assertNativeTeardownOwned. A repair that is worse than the defect it repairs is the signal to stop patching. Root cause: I coupled ten clients into one schema. A ten-key clientIntegrations map forced every phase to touch every client's write path, so each repair round widened the blast radius. The evidence is that WP2 passed both rounds untouched — it is the only phase that changes one thing at one boundary. Replan: ship the modality fix and the API-keys row alone, re-scope desired state to Codex only, leave Claude Code's ingress gates entirely alone, and move Desktop behind Codex to be audited on its own. Same four deliverables, sliced along ownership boundaries instead of along a schema. Also fixes the stale citations round 1 flagged and round 2 found unfixed: restore is cli/index.ts:770 not :745, the sync is :756 not :757, the Desktop field is types.ts:458-459 not :456, and the doc lastmod dates are removed because the pages do not show them — the semantic claims were verified twice.
WP2 modality fix and WP3 api-keys row ship first and alone, because each changes one thing at one boundary — the only property that survived both audit rounds. Desired state re-scopes to Codex only; Grok gets its own later phase reusing whatever shape WP4 proves; Desktop moves behind Codex. Three exclusions, each an accepted finding rather than a convenience: Claude Code's ingress gates are not touched at all, the six file clients get no flag in this unit, and Desktop is deferred pending its own audit. Adds C7 for the foreign-home teardown hazard round 2 found, and a risk-register row for the failure mode round 2 actually demonstrated — a repair worse than its defect.
…are different inputs The audit caught a real design error, not a nit. My helper mapped an audio-only model to [text], which advertises a capability the model does not have — and that input is reachable three ways: ocx models add --modalities audio, POST /api/custom-models, and provider discovery. So the helper now returns null for incompatible and the builders drop the row, while a model with NO declared modalities still gets [text] because unknown is not incompatible. Omitting a model costs a row in a picker; fabricating text costs a model that fails at call time with no explanation. Also replaces the grep-based verification, which would false-positive on 'audio' appearing in a model id or another provider's preserved block, with a parsed check of providers.opencodex.models[*].input. Completes the carried-forward ledger the audit found incomplete: every finding from both rounds now has a status and an inheriting phase, with the deferred file-client work named FOLLOWUP-FILECLIENT-01 rather than left pointing at a phase that does not exist. Renames the two pending docs so decade order matches phase order.
…t config gjc refused its entire config with /providers/opencodex/models/30/input/2: Invalid option: expected one of text|image, and fell back to its built-in list — every routed model gone. Index 30 is zenmux/meta-muse-spark-1.1, which advertises audio. Our internal vocabulary is text|image|audio and both Pi and Gajae accept only text|image, so the exporters were copying a value that costs the whole file. Pi carried the identical bug, unobserved only because its config was empty here; it returns an EMPTY model config on a schema failure. Same class as the Codex 'video' incident that showed zero apps, fixed at the client-dialect boundary rather than in ExportModel or normalizeExportModels, so the management and CLI surfaces keep carrying audio verbatim. Unknown and incompatible are treated differently: nothing declared falls back to text, since every routed model takes prompts, but a model declaring only audio is dropped rather than rewritten to text — that input is reachable via ocx models add --modalities audio, /api/custom-models, and provider discovery, and claiming text would fail at call time with no explanation. Test proven by reverting the fix: 5 of 7 cases fail without it, including the whole-catalog assertion the existing per-entry tests lacked while the real file was broken. Existing 91 export tests unchanged.
…t a unit test The client ships its schema as source, so it can be imported and run against our real emitted bytes. Feeding it an audio-bearing entry reproduces the user's exact error text from models-config-schema.ts:141, which makes it a real oracle rather than an approximation. A/B on the emitted file: HEAD~1 fails at models[31].input[2], HEAD passes, and the model count is unchanged at 34 — meta-muse-spark-1.1 is still exported, now as [text, image]. The fix removes a rejected value, not a model. Also records a method failure worth keeping: the first A/B reported BEFORE -> PASS because the git stash used to revert the file conflicted and never removed the fix, so the 'before' run measured the fixed code. Re-done by extracting HEAD~1 directly. A verification that silently measures the wrong build and reports green is the exact class of failure this unit exists to stop.
…y 'Not applied' The blocking finding is a contradiction I wrote myself: the doc argues at length that issuing a credential is not applying an integration, removes applied from the model, and drops keys from the Applied total — then renders IntegrationStateBadge, whose current/absent labels are exactly 'Applied' and 'Not applied' in all six locales. The live zero-key card says 미적용 today. The row now carries no badge; the detail line is the state, and an unsettled read gets its own key rather than borrowing the badge's 'Unknown'. Also: the summary labels move with their scope, because Detected silently goes 5 to 4 on this machine the moment the phase ships and bare 'Detected' never said it counted clients. The pinned test value is updated, not deleted. And two test-quality fixes: failExtraSources fails five sources at once so it cannot show an API-key failure alone leaves client totals alone — the fixture gets an independent keys control and a settled native response — and the keyboard assertion queries tabbable descendants with Enter/Space activation instead of counting buttons, which would pass through the exact regression it is meant to catch.
…f hiding it I declared a keyChecking string and never wired it: the null branch still returned detailKey null, so with the badge gone a cold or failed read would have rendered a row with no state at all. And ApiKeysOverviewRow kept the client unknown/absent/current triple as dead fields, which is an open invitation for the next author to reconnect the badge and undo the phase. So the state is now credential-native — checking, unavailable, none-issued, issued — and every branch names a detail key. That forced a real fix rather than a wording one. loadApiKeyCount collapses in-flight, network failure, non-ok and malformed into the same null, and useDataSurface reads that as a successful empty result with no polling, so 'Checking…' would persist forever after a failure. Indefinite progress copy is its own lie. The read now distinguishes settled-failure from pending, and the row shows 'Key status unavailable' for the former. Summary label becomes 'Configured clients' in all six locales: the reviewer read each one and 'applied' fails differently in each — awkward in English, in-progress in Japanese, and Clients aktiv silently changes the metric since a stale row counts as applied without being active. Enter/Space activation moves to the browser step: happy-dom 20.11.1 does not model native default activation, so the assertion as written would have been fixed by calling .click(), which proves nothing about the keyboard.
The reviewer caught that my previous amendment did not compile: prose said keysRow(state, count) while the diff still declared keysRow(count), the body referenced an undeclared state, the input phase was compared against an output value, and neither OverviewSources nor buildOverviewRows nor the component was ever threaded. A design that cannot typecheck is not a design. The deeper half was also wrong. My loadApiKeyCount rewrite was behaviorally identical to readOptional — still resolving successfully with null — so it could never produce the failed state the row needed. It now THROWS on non-ok and malformed reads, which is what yields failed-cold/failed-with-stale, and the component derives the phase from keysResource.state.kind the same way clientsSettled already does. Aborts need no special case: client-resource discards an aborted generation before publishing anything. state is no longer dead output: data-key-state on the wrapper consumes it, so the four states stay testable and stylable without a client-vocabulary badge. Test matrix rewritten around the four credential states including the settled-null case that must read unavailable rather than none-issued, plus a deferred response proving Checking before settlement. Verification and accept copy no longer reference the removed vocabulary, and the why-not-a-card prose is back under its own heading.
…hree partial ones The model passed; the document did not. Two diffs described the same buildOverviewRows call with different fields, ApiKeyReadPhase was used without an import line, and the OverviewSources change was asserted in prose but never shown. An implementer reconciling those by hand is how a reviewed design still ships wrong. All component wiring now lives in the Render section as a single diff: the import, the resource predicate, the phase derivation, and exactly one buildOverviewRows call. The interface change is shown against the real :82-93 block. Also fixes a contract leftover the reviewer caught: keysResource still carried isEmpty: value => value === null, but null stopped being a value the loader can produce once it throws, so the predicate would have outlived its contract while classifying nothing. Adds a loader test file, since the contract changed from returning null on anything bad to throwing on it, and a mounted failure case covers one of five throw paths. The non-array body is called out specifically: readOptional used to turn it into 'no keys issued', the exact false claim this phase removes.
keyPhase is required on OverviewSources, and both test sources() helpers build one without it, so neither file compiles until each defaults to settled. Said explicitly because 'no assertion changes' reads as 'no edits needed'. Also lists the new loader test under IN and in the focused test command; it was created by the plan but invisible to both.
The card claimed things a credential cannot be. Every grid card represents a client that can be detected, applied, or drift against its own config file; keys have none of those properties, so the card carried a 미적용 badge and inflated the Detected and Applied totals with a credential inventory. Keys now render as one full-width row above the grid, out of OverviewClientId entirely, with credential vocabulary — checking, unavailable, none-issued, issued — exposed as data-key-state rather than a client badge. The summary labels move with their scope to 'Clients detected' and 'Configured clients' in all six locales, since their numbers change by one the moment keys leave. The interesting half is the failure path. loadApiKeyCount used to swallow a network error, a 500, and a malformed body into the same null as an empty list, and the data surface read that as a successful empty result with no polling — so a failed request rendered as 'No keys issued', a claim about the account of the user we had no basis for. It now throws, the component derives the read phase from the resource state, and a failed read says 'Key status unavailable' instead. One tab stop: a plain button, no stretched-title overlay, since a row with no switch has no nested-control problem to solve. gui 564 pass, lint and lint:i18n clean, root typecheck clean.
…rom the live proxy The user's 10100 proxy runs the installed build without this change, so the row was observed from a second proxy started off the dev tree on 10399 under a mktemp OPENCODEX_HOME, then stopped by PID. 10100 answered 200 throughout. A dev-tree start against the real home would have rewritten the user's client configs mid-session. At 1440px: one full-width row between the summary and the grid, no badge, and the grid starting at Codex CLI with no keys card. At 390px it keeps its shape without clipping. agbrowse resolves the control as a real button in the accessibility tree — the Enter/Space assertion happy-dom could not make. Also records that lint rejected the two throw strings as untranslated UI, and why exactly one scoped disable is correct: they are rejection reasons the user never sees, and a blanket disable would hide the next real one.
The ten-key version failed two audit rounds, and 006 diagnosed the coupling itself as the defect: ten clients with different ownership rules, teardown callers and migration histories in one schema change, so every repair widened the blast radius. The two phases that shipped clean each changed one thing at one boundary. So WP4 is now Codex only: one clientIntegrations.codex key in a shape WP6 and WP7 can extend, field-scoped persistence through mutatePersistedConfig, gates on Codex's own automatic re-apply paths, a write flight keyed by CODEX_HOME with a fresh intent read before each irreversible write, and startup convergence that re-runs the remover behind assertNativeTeardownOwned so an ocx start from another OPENCODEX_HOME cannot strip a foreign service's state. Explicitly removed rather than quietly dropped: Claude Code's ingress gates (round 1 #1 — this unit does not touch them at all), the six file clients (deferred as FOLLOWUP-FILECLIENT-01), Grok and Desktop, and the four-client native contract that belongs to WP5. The auth-mode sentinel finding is answered rather than handled: a Codex-only flag never creates or modifies the claudeCode block, so the migration path is unreachable, and a regression test pins that.
… just concurrent Four High, all accepted. But the shape of this round is different from the two that failed before it: the reviewer independently confirmed the doc is Codex-only, the flag is extension-safe, and the auth-sentinel claim holds even under a second key. Three rounds of coupling findings vanished in one pass, so the 006 diagnosis was right. What remains is all Codex. #1 and #2 are one defect: I designed a check where I needed a lock. Re-reading intent before a write narrows a window rather than closing it, and my own test would have passed while the bug was live because it flips OFF before the check instead of between check and write. The ownership preflight fails open by design, which is defensible for a route a human is watching and wrong for unattended startup convergence — and my ordering put both journal repair and my own lock file ahead of the check that was supposed to protect them. #3 is an artifact I did not know existed: restoreNativeCodex never touches models_cache.json, so a converged OFF can still advertise routed models. Same shape as the WP2 bug — invisible to a test asserting only the artifacts it already knew about. #4 is the false-green class again, one phase after I wrote a section about it: I gated syncModelsToCodex under ocx restore back without deciding what that documented reverse switch does when Codex is durably OFF.
D1: re-reading intent before a write narrows the race rather than closing it, and the test I wrote for it flipped OFF before the check instead of between check and write, so it would have passed while the bug was live. Replaced with one per-CODEX_HOME linearization lock covering both intent commits and native write sections, model gathering left outside it, and deterministic seams after authority approval so the race is testable at the point it actually exists. D2: the ownership preflight fails open, and readServiceInstallState collapses corrupt, unreadable and missing-mirror into the same null. Acceptable where a human reads the refusal; wrong for unattended startup convergence. Now a tri-state owned|foreign|unknown that fails closed on the latter two, resolved before journal repair and before any lock artifact exists, with the lock moved outside CODEX_HOME. D3: restoreNativeCodex never touched models_cache.json, so a converged OFF could still advertise routed models. Cache restoration joins the remover and the observed-state read, with crash fixtures at the cache boundary. D4: ocx restore back is an explicit enable verb — it persists ON and applies, while api/sync 409s and ocx sync refuses by name. A skipped sync returns ok:false with a reason instead of an ok every caller reads as success. D5-D7: the thrown lock-contention branch, the shared legacy catalog backup across two CODEX_HOMEs, and the docs-site lifecycle pages this phase changes.
…this code yet Three closed, four open, five new blockers. Unlike the earlier divergence this is not the phase map: the reviewer re-confirmed the Codex-only re-scope holds. It is narrower and more honest — Codex's write path was never designed to be interrupted. The concrete proofs. The management refresh dependency is an opaque () => Promise<void> that gathers and writes in one call, so 'gather outside the lock, commit inside' cannot be done to it. The history write is two 5s SQLite busy waits plus a synchronous 500ms sleep plus row-dependent rollout writes, so my 'bounded synchronous commit' can hold the lock ~10.5s on the server event loop — a dashboard OFF would stop serving every other client, which is the exact thing this feature exists to prevent. I also deleted a guard I did not recognize: startup suppresses journal repair under an external model_provider, and I replaced that authority with service-home ownership. Satisfying this design needs a gather/commit seam in the management contract, history moved off the event loop or given a fail-fast mode, an async lock protocol with deadlines and fairness, and a hardened per-user lock namespace. That is a concurrency substrate, not a boolean with a check. Recording the Codex switch as blocked on that prerequisite rather than patching a third round. The two shipped deliverables stand on their own evidence; WP6 and WP7 depend on the same substrate, which is why the chain stops here instead of continuing to the next work-phase.
… path 001 catalog seam: draws the exact gather/commit line through refresh.ts and traces all 16 management callers. Hardest problem named — stopping a locked commit from applying a candidate gathered against an obsolete config. 002 history: maps every blocking operation and separates server-process from CLI-process callers, since a CLI blocking itself is acceptable and the server blocking is not. Recommends worker isolation plus fail-fast convergence, and names the constraint that history traversal has no finite work bound. 003 lock: async SQLite lock with finite deadlines and acquired|busy|refused, namespaced per-user under the real home rather than a shared tmpdir, keyed by a realpathed home. Hardest question is identifying a missing home across case-sensitive and case-insensitive filesystems. 004 ownership: one admission order for start, ensure and routes, with foreign and unknown failing closed before any artifact exists. Creation is proven by a ledger recording baseline absence plus a post-image hash, not by a filename or marker — which is what makes absence restorable. Hardest case is baseline absence followed by native edits: preservation wins and restoration reports a conflict rather than deleting user data.
WP9 and WP10 are genuinely independent and both land before WP11, because a lock around an unsplittable gather-and-write, or around a ten-second blocking history call, is the failure the previous unit already proved. WP11 then has something bounded to wrap, and WP12 is last because its admission order must run before the lock module creates anything, so it needs the real construction sequence to point at. Thirteen criteria, each naming a live artifact rather than a unit test: C3 in particular MEASURES /healthz under real contention instead of asserting it. States plainly what this unit does not claim: the write path does not become transactional. A crash mid-commit still leaves partial state; what changes is that it is detectable and the next convergence re-runs against it.
010 catalog seam: staleness guard is a SHA-256 over canonical catalog-affecting config plus the exact base-catalog bytes-or-absence and target paths, so a commit refuses a candidate gathered against a config that has since moved. Candidates are consumed once and branded module-private. 020 history: the whole SQLite/rollout mutation moves into an owned Bun Worker with serializable messages and explicit death handling. Automatic server work gets 100ms/one attempt/no sleep while explicit CLI work keeps the current 5000ms/two attempts — the asymmetry is the point, since a CLI blocking itself is acceptable and the server blocking is not. C3 measures six /healthz calls and a live stream during deterministic cross-process BEGIN IMMEDIATE contention rather than asserting responsiveness. 030 lock: explicit and default homes resolve identically and must exist, both realpathed, case-folded only on Windows. Missing homes are refused before hashing, admission or namespace creation, which is what dissolves the case-sensitivity ambiguity 003 flagged as its hardest question. 040 ownership: provenance is a baseline record plus a verified post-image hash, so a baseline-absent artifact is deleted only when the hash still matches and a user edit is preserved and reported as a conflict instead. The server rereads persisted intent before gather and again under the lock.
The owner found opencodex missing from gjc's /provider list, and Pi refusing to
start at all: models.json was three bytes, {}, which violates its own schema
because providers is a required key. The journal shows both were disabled
during this project's own investigative sessions yesterday and never
re-applied.
Two defects, both this unit's business. First, disable can leave a file that is
invalid rather than merely empty — removing the last member of a required
container is not the same as removing a member, and the writer does not
distinguish them. Second, nothing reconciles an integration that is off but
should be on, which is the desired-state gap seen from the other side: a
disable performed for one purpose stayed in effect for a day, silently, across
restarts.
Amends 040: the artifact inventory needs a baseline class for containers the
client requires to be non-empty, so a remover can tell restore-to-absent from
restore-to-valid-minimum and never writes a bare {} unless that is what
preceded us.
Both files were re-exported and verified — gjc passes its real schema with the
user's own profiles block preserved, and both now carry the WP2 modality fix.
… parallel authors collided FAIL, 7 High. Four of them are one defect: I dispatched four phase docs in parallel and each wrote a correct design for its own boundary, then they met. Two phases both declared ownership of integrations/codex.json with different required version-1 shapes; three each defined the /api/sync contract; the lock forbids awaitable work while history lives in a Worker, so the lock never serializes history at all; and 16 management callers were rewired to a direct commit helper that no phase's admission covers. That is the prior unit's defect at four times the scale, and I had written the fix into that unit's own synthesis: when phases share a contract, one phase owns it and the others consume it. So WP8b lands first and owns the record schema, the sync response, the convergence entry point, the generation counters and the module names. Three findings are real design errors rather than composition. mutatePersistedConfig documents that a writer ignoring the coordinator can change bytes after the final check, so recheck-under-lock does not linearize — it needs a generation verified before and after commit. My staleness guard hashes contents, which an A-to-B-to-A cycle passes, while C2 promised revision detection. And the lock namespace derives from homedir(), which reads HOME/USERPROFILE, so a service and a CLI for the same user can take different locks and defeat exclusion entirely; my tests set both consistently and would never have caught it. Also corrects a false claim the audit caught: this unit DOES ship a config switch. clientIntegrations.codex plus convergence that obeys it is a switch even without a setter or GUI, so the plan now says exactly what ships. Three technical bets survived independent verification: Bun Workers carry the proposed messages, a crashed lock holder does not wedge the OS lock, and refusing a missing CODEX_HOME does not break first run.
The record gets a single owner and a schema where every section is OPTIONAL at v1, so a record written before a section existed is valid rather than malformed — that is what lets WP10 land before WP12 with no migration, and it is exactly what round 1 got wrong with two incompatible version-1 shapes. convergeCodex becomes the only way Codex-owned bytes are written, with a discriminated outcome instead of the bare catch that swallows everything today. Best-effort callers keep their 2xx; what changes is that the outcome is visible. An import-guard test keeps that true as callers are added. Two generation counters replace the content hash, because mutatePersistedConfig documents that a writer ignoring the coordinator can change bytes after the final check, and because a content hash passes an A-to-B-to-A cycle. Both are read before AND after the native commit: a post-commit mismatch is not converged, it is unresolved and re-converged. That is weaker than a transaction and says so. History gets its own cross-process lock held inside the Worker across the whole unit, because the real path writes the manifest and rollouts outside its SQLite transaction, so two processes corrupt each other through files SQLite never guarded. Ordering is native then history, never the inverse. And the baseline classes come straight from the live incident: a removal that empties a container the client requires is a third class, not the same as removing a member.
One closed, eleven open, five new. The cause is nameable: 005 says it owns the record, the route and the entry point, and 020/030/040 still contain their own versions because I never rewrote them. The reviewer's list of contradicting sections runs thirty-odd entries. A contract nobody collected is a fifth opinion, which is round 1's defect one level up. The most valuable finding came from the reviewer EXECUTING a claim rather than reading it. I had accepted their round-1 fix of using os.userInfo().homedir instead of homedir() for the lock namespace; they ran it on our pinned Bun 1.3.14 and both returned the fake HOME. I reproduced it. But the same probe shows the way out — uid and username are real — so the namespace keys on effective-user identity, uid on POSIX and account SID on Windows, never a home path. Three new High findings accepted. The request let the CALLER choose apply vs remove, so /api/sync while OFF could skip instead of removing residue; it now carries converge|observe and the direction comes from admitted intent. WP8b as written cannot land first because it declares a runtime entry point while being OUT of every behavior. And the generation counter would treat its own successful commit as interference, since a bump-on-every-commit counter always mismatches after a write — it needs an expected N-to-N+1 transition with a transaction id. Also reverses my own scope creep: present-required-nonempty came from the live Pi incident and belongs in FOLLOWUP-FILECLIENT-01, not in a Codex unit. Housing a finding in the wrong unit is not housing it. The correction is to collapse the four phase docs into the contract rather than run a third round of parallel edits, because two rounds have now shown four docs cannot be kept consistent by review alone.
admission reported ownership: "owned" unconditionally, from a helper that returns ok:true when the service state file is corrupt. That is correct for a teardown route a human just invoked and wrong as authority for an unattended write - "could not read" arriving as "belongs to me". The probe does not ask whether a job is loaded. Installation writes the definition BEFORE the state file and embeds CODEX_HOME inside it, so an interrupted reinstall leaves valid state for one home beside an installed plist for another; a registration-only probe calls that owned. On macOS a logged-out user has the plist on disk with no GUI domain at all, so nothing reads as loaded while a foreign definition sits there. So it reads the definition, parses the homes, and reports what it saw. The comparison belongs to the caller - a probe returning a verdict would be deciding from half the evidence. Measured rather than assumed: launchctl exits 113 for a missing service and 112 for a missing domain, and only the first is an answer. systemctl prints not-found and exits ZERO, so on Linux the value carries the answer and the status carries only whether the question reached the bus. LoadState alone says nothing about whether the loaded bytes match the file, so NeedDaemonReload is part of the same query. Windows reports unknown for now. Its definition is a chain - the task XML names only the launcher and the homes live in the batch wrapper - and a probe that parsed the XML and stopped would find no homes and read that as agreement. owned means no persistent service claim was observed. Not exclusivity: two foreground processes on one home both read owned, correctly, because neither installs a service. Keeping them apart is the write lock's job.
Flipping admission's `ownership !== "owned"` check to `if (false)` left the whole suite green. The fixtures pin ownership to owned, so nothing ever exercised the refusal - a guard with no test is a guard someone can delete without hearing about it. Four cases now hold it down: foreign refuses on the service-home authority and names the other home, unknown refuses with wording that says the proof is missing rather than that someone else owns it, neither refusal creates anything, and an admitted snapshot carries the value the probe returned rather than a constant. The two refusals deliberately do not read alike. One means another install owns this home; the other means the question could not be answered. They need different actions from the user, so they get different sentences.
A second copy of the probe landed in service.ts alongside the real one in service-manager-probe.ts, and it did not compile: lstatSync was never imported and it called a nativeServiceRegistrationPresence that does not exist. HEAD was failing typecheck. The duplicate was mine. I wrote it against the plan without checking whether the plan had already been implemented, and it was committed along with the work that made it redundant. service-manager-probe.ts is the version that stays - it compares definition paths rather than only asking whether a registration exists, which is the stronger check.
The assertion still described the old three-field shape. A boolean cannot tell "no such service" from "no such domain" - launchctl exits 113 for the first and 112 for the second, and only the first is an answer about whether anything is installed. The status had to survive to the caller, so the fixture follows.
The ownership probe went through four review rounds and every one found a real defect. Recording them because each was a different shape of the same error. Round 1: if every unanswerable probe returns unknown and unknown refuses, a fresh machine with no reachable service manager refuses every Codex write. Headless macOS, a container with no user bus, a host without systemd. That is worse than the bug being fixed. Round 2: I had accepted that a launchd plist outlives a failed install, then reasoned about Linux as though a systemd unit were not also a file. It is, and it is written before daemon-reload for the same reason. I also wrote that runLaunchctl already carried the evidence one layer up; it does not - it collapses the status into a boolean, which is exactly what made 113 and 112 indistinguishable. Round 3: gui/<uid> and user/<uid> are separate domains. Measured: the shipped agent answers 0 in one and 113 in the other. Round 4 settled the rule the first three were circling. Only two things prove absence without an answer: the platform has no such backend, or the init that would hold it is demonstrably not running here. Everything else is silence, and silence is unknown.
…d for it I argued the admission snapshot was not decorative in a phase that does not gate on it, because the lock still throws on a changed authority ID - so it answers "is the world I looked at still there". It cannot answer that one either. configDigest hashes the persisted config.json, while the bytes this operation writes come from the native config.toml and the port it was called with. Neither is in the hash, so two processes injecting different ports produce the SAME authority id and the staleness check sees nothing. So this phase gets its own witness over the inputs that determine THIS mutation: native config identity, persisted identity and generation, the candidate inputs including port, canonical targets, journal identity. hashAuthority keeps ownership and stays as it is - the gate phase needs it. A second narrower hash for a narrower claim, not a weakening of the first. Also writes down the publication call with each expected value's real source, why direction is apply while the history operation can be finer, the minimum a race child must seed now that ownership is not observed, and six ways the race could still pass while broken. Acceptance rows retagged: A1 and A2b are exclusion and land with the lock; A2 is permission and moves to the gate phase.
Three audit rounds on the activation edge, and the third took apart the answer the second one liked. "Take the lock without letting admission gate the call" keeps Windows and headless Linux working and still gives the lock a production caller, which is why it beat my platform split. But admitCodexWrite refuses before it constructs a snapshot unless ownership is owned, so every snapshot it makes carries owned - the field cannot hold unknown at all. Bypassing the producer leaves no way to build one; not bypassing it makes the call gated. Copying the value would not help either: ownership is in the authority hash and the lock compares only that hash, so a copied unknown matches itself and detects nothing. What this phase needs is a different type with a different producer - a write coordination observation whose comparison id contains only evidence that can be independently re-read under the lock. Admission stays what it is, and the gate becomes its own phase. Three more from the same round. Compensation cannot blindly call restoreJournalState, because it restores whatever journal is at the path and that need not be ours. Failed compensation must throw, or the lock commits a row describing an apply that did not finish. And publishing direction apply while intent is off contradicts the convergence contract in the same file that says the caller never chooses direction.
The design hashes the computed candidate bytes and opens the lock before writeJournal. Those cannot both hold as written: content and profileContent are not final until seventy lines after the journal write, so at acquisition the witness would have nothing to hash. Hoisting resolves it. The region between the journal write and the artifact writes is string transformation - no write, mkdir, unlink or rename in it - and its one filesystem touch is an existsSync on the catalog paths, which writeJournal does not touch. writeJournal is also called with configContent supplied, so it snapshots what it was handed rather than rereading config.toml. Neither half depends on the other having run. The alternative was shrinking the span to the three writes, which puts the journal back outside the lock and reopens the hole this phase exists to close.
…d never did A third audit round caught a factual error I had already repeated once: I wrote that the history Worker derives its operation from admitted intent. It does not. The parent sends request.operation in the message (history-job.ts) and the Worker executes that value. The durable row stores only apply|remove, so it cannot reconstruct skip / apply-opencodex / migrate-openai. Two honest options and this phase takes the second: widen the row, or schedule nothing durable. WP-R1c records the direction - what the row can truthfully hold - and leaves the durable-schedule question to whichever phase actually needs a crash to resume history. Recording a direction and calling it a schedule would be the overclaim this document has now corrected three times. The witness also changes shape. Enumerating inputs was already incomplete: InjectCodexOptions.catalogPath resolves through the filesystem, and the emitted bytes depend on the passed config, which may differ from the persisted one. So the witness hashes the computed candidate bytes instead of the inputs that produced them - an input that changes the bytes changes the hash whether or not anyone remembered it. And beginTransition returns a conflict rather than throwing, so its result is checked immediately, before any file moves. A failed write between the two files leaves the row clean and the first file replaced, which is what the journal being written first is for - the callback compensates before rethrowing, and the test injects the fault on a re-injection too, where the journal is already marked. Three stale statements from before the scope split are corrected: R1c does not gather manager evidence, does not depend on R1b, and A9 names the span it actually locks.
…ter all
The last audit round rejected "schedule nothing durable" as unimplementable:
beginTransition writes the schedule fields unconditionally, a positive row
without a complete schedule is rejected, and nextRetryAt is required. That
correction was right, but my first answer to it was not.
The durable row needs the operation, and the operation is not a value that
only exists later. deriveCodexHistoryOperation({ direction, resumeHistory,
legacyMode }) computes it from inputs the caller already holds when it builds
the witness. So the row stores what the caller actually decided - neither a
deferred schedule the API forbids, nor a widening nothing needs.
…ved today The fifth audit round found two more, and the second is a defect that exists today rather than in this phase's scope. Deriving the history operation at publish time does not put it on the row: the schema has history_direction and no operation column, and BeginCodexTransitionNext has no operation field. Supplying it means widening the row, the type, and the API - which this phase now owns. And updateCodexHistoryTransition has no production caller, so a completed or skipped history job leaves the row permanently pending. The transition is published and never resolved. WP-R1c now publishes the terminal result after the job returns, making the row reflect what actually happened for the first time. Two rules the same round forced: compute the operation once, before acquisition, and pass that exact value to both the publication and the job - re-deriving after the awaited acquisition would let a mutable config.syncResumeHistory diverge between what was scheduled and what ran. And the acceptance reads the committed row, asserting the stored operation equals what was dispatched, with a test that swapping the operation fails.
It has had none since it was written - twenty passing tests, real two-process contention proven, and nothing in src/ calling it. That is defect #10 of this unit and the reason WP11 was folded into WP12: a mechanism with no consumer can only ever be exercised with a fabricated object. injectCodexConfig now holds it across the journal write, both config and profile replacements, and the injected-state marking. The history job stays outside; it has its own cross-process lock and the N -> H order is deliberate. Four things this needed that "wrap the writes" would have missed. The witness is not an AdmissionSnapshot. admitCodexWrite refuses before it constructs one unless ownership is owned, so its snapshots can never carry unknown - reusing that type would have meant either gating the call, which refuses injection on every Windows machine and on Linux without a user bus, or hand-building a snapshot that claims an admission nobody made. The new type authorizes the WRITE, not the DECISION to write, and it hashes the computed candidate bytes rather than an enumeration of the inputs that produced them: an input that changes the output changes the id whether or not anyone remembered to list it. The callback publishes before it writes. assertPublished runs after the callback returns, so writing first would replace every file and only then fail, with SQLite rolling back and the filesystem staying changed. Compensation uses exact pre-images and always throws. restoreJournalState restores whichever journal is at the path, which need not be ours, and returning a partial result would let the lock commit a row describing an apply that never finished. Eligibility is decided BEFORE acquisition. A home routed before this substrate existed cannot have its first coordinator row created - the guard that refuses is right, because installing {0,null} over routed bytes erases the evidence an interrupted transition needs. That describes every pre-substrate install, so "try the lock and fall back" would have entered a refusal path on the whole installed base. Those homes keep the sequence they have always used, and adopting them into the coordinator is the next phase, already designed in 005_contract.md. So the honest claim is narrower than the one this unit set out to make: writes are coordinated for clean first applies and for homes with a valid coordinator. The installed base is not covered yet.
…line up The sixth round found the last three gaps, each verified before accepting. A version bump does not recreate the database - initialize() rejects any nonzero version it does not know. So the migration is written down: v1 generation-zero rows migrate with a null operation, v1 positive rows are unknowable from direction alone and refuse unattended use as legacy-ambiguous rather than being guessed, and new rows require a non-null operation, since accepting null would preserve the defect this exists to close. The schema rejects impossible pairs like apply + restore-openai outright. The terminal update is a compare-and-swap, not a blind write: the callback returns the generation and txId it committed, and that receipt is what the post-job update matches on, so an overtaken job cannot overwrite the winner. Every outcome is handled, including busy - ignoring it is the "pending forever" defect wearing a different name. And the outcome union does not map onto the state union one-to-one. The mapping is written as a table rather than an as-cast across two unions that happen to share words, because a converged-with-zero-rows job is not a skipped job, and conflating them is how a user reads "done" for something that did nothing.
… not exist The seventh audit round checked the claims instead of the prose, and neither held. The durable retry handoff named no artifact and no consumer. It needs neither: an exhausted retry is itself a terminal observation, recorded on the same row through the same CAS as blocked with db-busy, so nothing is left pending waiting for a handoff that was never defined. The later convergence that reads that state is the consumer. And I had written that the outcome-to-state mapping was "a table written down in full" when the table was not in the document. It is now, all eight cases, because the unions do not line up and a cast that compiles because the words overlap would let any convenient mapping pass. The one to watch is skipped, which maps to converged with no reason: opting out of history resume is a completed decision, and marking it blocked would retry a thing the user asked not to happen.
…lready audited Round eight, and this one was not a missing detail - the mapping I wrote was actively worse than the contract it replaced. A terminal busy cannot record itself. The same update needs BEGIN IMMEDIATE, which is the resource that was busy. The contract from WP8b already answers this: preserve the pending schedule and report record-write-failed observationally. My version recorded blocked/db-busy, which is a sentence the code cannot make true. The rest of the table had its own errors. converged.rows is a mutation count, not the durable pendingRows/backupEntries, which come from the final probe. A skip performs no probe, so it stores null counts rather than a manufactured zero. And several of my reason literals mapped to the wrong states entirely. So the mapping is not reinvented. It is the one 020 already defines, applied rather than restated, with the two count facts that stay true regardless of which row applies.
Two failures from the full suite, both mine. An unclassifiable native state was refused outright. But `indeterminate` means the classifier could not read what is there - an older profile it cannot parse, for instance - which is an ordinary re-injection, not a hazard. It correctly cannot seed a coordinator row, and that is the whole extent of what should be refused. Refusing the injection broke restore on homes that work today, which the shipped journal tests caught and review did not. The other was a reflow: a formatter split one call across lines, and a test that reads inject.ts as text to prove the catalog write stays inside its serialization no longer matched. The assertion is doing real work - it is how that invariant is checked at all - so the source went back to one line rather than the test being loosened.
My "adopt the existing classification" still took only part of it. Three rows were missing: Worker error / malformed terminal IPC / early close rereads durable state then lands unknown/worker-died; a superseded identity is a typed overtaken result with no self-retry by the loser; and terminal N update failure is unknown/record-write-failed with pending preserved. One of them is not free. Malformed terminal IPC is ignored today until the watchdog reports timeout, so the parent cannot tell a dead Worker from a slow one and would classify both as timeout. Telling them apart - a different reason with a different operator action - requires treating an unrecognised terminal message as a death signal rather than silence. That is a small change to one switch, owned here because the durability claim depends on it.
The tenth round found the second and third after the first was written down. The parent handles only message and error. An unrecognised type is ignored until the watchdog calls it timeout; an early close is not listened for at all, so a Worker that dies without erroring also becomes timeout; and a malformed message with a RECOGNISED type reaches an unchecked cast and reads as converged with undefined fields - the worst of the three, because it reports success for work that may not have happened. The parent now validates the result fully - matching requestId AND jobId plus each type's payload - and subscribes to close and messageerror as death signals alongside error.
The residual from the last audit round, made explicit before build. The Worker always closes after posting its result, so a close handler that overturned a valid success would report every completed job as a death. The tests that hold the boundary down are named: a malformed message with a recognised type is rejected, an unrecognised type is a death signal, messageerror and early close classify as worker-died, and a terminal message followed by a normal close leaves the success intact.
Two issues and one PR, named rather than implied. #1048 is WP13: the composed acceptance program. Every mechanism in this unit proved itself, and each proof was phase-local - which is the exact shape of failure the substrate exists to prevent. Thirty-six production entry points are censused in 050 and nothing yet asserts they all converge. #1049 is adoption, and it was not planned. Implementation found that assertInitialStateCanBeCreated cannot seed a coordinator row over routed bytes, which describes every install predating this substrate. Gating injection on the lock would have broken re-injection for all of them, so eligibility is decided before acquisition and those homes keep their existing path. The adoption design already exists in 005_contract.md. WP14 gets no issue because PR #998 is the deliverable.
Six conflicts, fifteen hunks, resolved once. A rebase was tried earlier and stopped at commit 74 of 129 with the same files recurring; the branch is longer now, and merging resolves each hunk a single time while preserving commit SHAs and review anchors. inject.ts took dev in all seven hunks. Every one of ours there was a formatter reflow of the base while dev carried the real tri-state fastMode change from #1022, so taking dev IS the semantic composition rather than a textual keep-both. The write-lock pipeline lives elsewhere in the file and is untouched - ensureFastModeFeature still runs before the candidate bytes are final, and the witness still hashes those final bytes. catalog/sync.ts is the one that needed judgement. Dev still wrote the catalog with a raw atomicWriteFile; keeping that would bypass the catalog write lock this branch added. The permit writer owns the write, and dev's account-bound row count and its restoration helpers survive around it. The rest compose: our permit-guarded cache invalidation beside dev's cooldown worker, our tri-state service evidence beside dev's reworded accessor comment, clientIntegrations beside dev's account-qualified subagentModels contract, and our ownership-aware provider map beside dev's routed-slug replacement map. Verified in a scratch worktree before landing: typecheck clean, 111 focused tests passing, and the lock call edge still reachable from inject.ts.
The merge brought in a header-validation test that wires `refreshCodexCatalog`, the best-effort dep this branch replaced with the convergence entry point. It passed on dev and passed here in isolation, and failed only once both sides met: the counter never incremented because nothing calls that dep any more. Every other test in the same file already uses catalogConvergenceFactory. This one now matches them.
…low one Two things the call edge needed that wrapping the writes alone did not give it. The transition was published and never resolved: updateCodexHistoryTransition had no production caller, so every completed or skipped history job left the coordinator row permanently pending. injectCodexConfig now carries the receipt the committed transition produces - the generation and txId - and resolves the job against it through the CAS, so an overtaken job's late write loses and is not overwritten. Opting out of history resume records converged with no reason, because a user's decision to do nothing is not a failure and must not be retried as one. The Worker boundary was three gaps, not one. An unrecognized message type was ignored until the watchdog called it a timeout; an early close was not listened for at all; and a malformed message with a RECOGNIZED type reached an unchecked cast and read as converged with undefined fields - success for work that may not have happened. The parent now validates the payload and the ids, and subscribes to close and messageerror as death signals. Neither can overturn a settled success: finish is idempotent, and the Worker always closes after posting its result.
The Codex toggle shipped a call site passing "codex" to toggleNativeIntegration, and the server has accepted it since. The GUI type stayed "claude" | "grok", and the runtime guard set with it. Nothing caught this locally: GUI typecheck runs from gui/tsconfig.json, and `bun x tsc --noEmit` at the repository root does not read that file. The remote suite I use for verification does not either. CI did, on every job at once. Both the type and NATIVE_CLIENTS move together - widening only the type would leave the guard rejecting a codex response at runtime.
The Codex card renders alongside the file client now, so "the first switch on the page" is a fact about sort order rather than about the client under test. Two tests picked their switch that way and got Codex's. They now select by data-client. The third asserted exactly one switch exists, with a comment saying only the file client has one - true when it was written, and no longer: Codex and Grok can both toggle in place. It names the owners instead of counting them, so adding another toggle cannot pass by keeping the count right for the wrong reason. All three passed on dev and on this branch separately, and failed only once merged.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this is
The Codex integration needed an ON/OFF switch. The switch itself turned out to be
cheap —
ocx restorealready returns Codex to its native path without stopping theproxy. Two audit rounds then found the actual prerequisite: Codex's write path was
never designed to be interrupted. This branch builds the substrate that makes an
interruption safe, so the switch can land on top of it rather than on top of a race.
Unit:
devlog/_plan/260804_codex_write_substrate/.What already ships here
Two user-visible fixes landed on the way in, both verified against real artifacts:
audioentry ininputModalitiespoisoned entireclient configs, so
/providerlisted neitheropencodexnorpi. Fixed at theclient-dialect boundary in
src/clients/config-export.tsand proven A/B againstgajae's real
ModelsConfigSchema.full-width row, and
loadApiKeyCountnow throws instead of collapsing a failureinto
null.The substrate (WP8b)
Modules under
src/codex/, owned once so later phases consume rather than reinvent:a CODEX_HOME-keyed SQLite coordinator holding the transition pair and history
schedule behind a conditional-UPDATE CAS, uid/SID identity that never derives from a
home path, a single owner for
integrations/codex.json, config generation counters,catalog admission with source evidence, and one exhaustive
/api/syncprojection.Design points that took several audit rounds and are load-bearing:
replace across two coordinators is not a CAS, and an old worker could replace
N+1 with a stale N.
os.homedir()andos.userInfo().homedirreturn an environment-controlled home while uid does not.and the models cache change what Codex can list, never where it sends traffic, so
catalog commits get source evidence and their own lock instead of borrowing it.
Why the tests look the way they do
This repository has a documented history of green suites over broken behavior: 91
tests passed while a real gajae config was broken, and 18 passed while every
history update failed (
new Database(path, {create:false})isSQLITE_MISUSEonBun 1.3.14). So every regression here is pinned to a concrete mutation and proven
red before green.
Two examples of what that caught. The residue check guarding coordinator
initialization read only
config.tomland treated every other routed byte asabsent, so a real generated profile classified as clean and the coordinator
installed a fresh
{0,null}over the only evidence an interrupted transitionneeded salvage. Separately, a
threadsrow taggedopenaiwhose rollout stillsaid
opencodexalso classified as clean — reachable, not hypothetical, becauserestoreNativeHistoryswallows a failed rollout write and then marks every rownative anyway.
The recurring failure mode across all of them was the same: treating an absence
as a guarantee. It has now been caught four times, including twice inside the fix
for it. The classifier returns
clean | residue | indeterminate, and onlyENOENTcounts as a clean surface.
Verification
bun run typecheckclean,bun run testgreen (8100+ tests across 535 files),bun run privacy:scanpassed,bun run lint:guiclean. The prepush hook reruns thefull suite on every push to this branch.
Every phase is audited by an independent reviewer that did not write the code and
reproduces defects by running them. Two verifier rounds on the substrate returned
FAIL before it passed. The WP9 plan has been through three audit rounds — FAIL with
8, then 5, then 3 findings — each one folded into the contract or the plan.
What the audits changed in the design
Worth calling out, because these were not cosmetic:
schema requires every positive generation to carry a history schedule, so doing so
would have meant scheduling history work that does not exist.
content evidence — including for sources that were checked and found absent,
since absence is what selects the default catalog path.
excluded by the config transaction, so catalog serialization became its own
permanent lock with a proven-acyclic ordering.
The switches, which is what this was for
Three of them land here, and the interesting part is that two were the same bug
in different files.
Codex had no switch at all — its card carried
toggle: null, so it was theone client on the integrations page you could not turn off. It has one now:
PUT /api/native-integrations/codex, plus the GUI toggle.Grok already had a working switch, and it lasted exactly one restart. The
toggle strips the fence from
~/.grok/config.tomland recorded nothing, soocx startcalledsyncGrokConfigunconditionally and wrote it straight back.Codex had the identical defect at
src/cli/index.ts:319— the restore worked,and then startup put the routing back.
So the substrate under both is a durable
clientIntegrationsmap, one key perclient. Absence means ON, and that rule carries the weight: a config written
by an older binary, an untouched one, and an explicit
trueare the same state,and reading any of them as OFF would silently unroute someone who never asked.
Only an explicit
falseis off, so a hand-edited"false"string degrades to ONrather than to OFF.
Turning a client off is not
ocx stop. The proxy keeps serving, otherclients keep routing, and only that client returns to its own path. That is
asserted rather than described: the management surface still answers afterwards
and the switch is immediately reusable, which a wedged in-flight guard would
turn into a 409.
Both startup gates moved out of
handleStartinto testable functions. They hadbeen inline in a 600-line path that binds sockets and installs services, so
nothing could reach them — and an untestable gate is exactly how an
unconditional force-sync survives this long.
Eleven mutations cover this work, each proven to redden a specific test and then
restored: absence read as OFF, re-enable storing
true, each gate removed andinverted, the sync failure no longer swallowed, the port not forwarded, intent
never persisted, a non-boolean
enabledaccepted, the in-flight guard nevercleared, and the client key hardcoded so Grok writes Codex's field.
The switches
Three of the four now exist, which is what this branch was originally for:
PUT /api/native-integrations/codexroute. Turning it off returns Codex to its native path and leaves the proxy
up, so the other clients keep routing.
ocx start. It now survives a restart.toggle: null, pendingthe documented standard-mode restore path.
Durable intent lives in one
clientIntegrationsmap in the config, read by thelifecycle path rather than inferred from what happens to be on disk.
Not done yet
The native write lock is not wired to production, and the desired-state commits
are not yet linearized across processes. That is the honest headline. WP11 built
the mechanism; an audit then established that its only consumer is WP12, and that
both things needed to exercise its API — a runtime
AdmissionSnapshotproducer andthe
inject.tssynchronous-native / awaited-history split — arrive there too. Astandalone WP11 could only have proven a fabricated snapshot drives the primitive,
so the phases were merged rather than shipping a mechanism nothing calls.
src/codex/codex-write-lock.tsdoes not exist yet. Until it does, the switchesabove are correct for a single process and not serialized against a second one.
Landed and verified: the catalog gather/commit seam (WP9), history isolation in a
Worker with a cross-process lock (WP10), the Codex/Grok switches, and the ACL
hardening repair below. Remaining: the lock plus its caller (WP12), the Claude
Desktop switch (WP7), and a composed acceptance suite (WP13).
One live defect fixed on the way through
The ACL success memo keyed on pathname, so a file unlinked and recreated at the
same name inherited the previous file's hardening without ever running
icacls.hardenStableLockFilehardens the coordinator database through exactly that path.The fix took four attempts, and each intermediate version was defeated by running it
rather than reading it:
dev:inoalone — ext4 hands an unlinked inode straight back, 100 of 100 cycles,while APFS reused none in 200. The macOS-verified fix was broken on the platform
CI runs on.
icaclschanges permissions anda permission change moves
ctime, so this rejected its own successful work andwould have failed closed on the first harden of every path on Windows.
chmod— on POSIX the mode is the whole mechanism andthere is no fallback, so a pre-existing permissive database stayed permissive while
the caller was told it had been hardened.
Identity is now
object(dev:ino, compared before and after the sequence) andfreshness(ctimeNs, stored from the post-harden read and deliberately notcompared across it).
Remaining activation gate: the NTFS
bigintinode behaviour is unverified. It ismarked as such in the source rather than asserted, and a pinned-Bun Windows probe is
required before this is called complete — if Bun returns zero or unstable values
there, every required Windows harden fails closed.
Verification
bun x tsc --noEmitclean,bun run privacy:scanpassed,bun run lint:guiclean.Full suite on a 16-core Linux runner: 8318 pass / 0 fail / 10 skip across 547
files.
Every phase is audited by an independent reviewer that did not write the code and
reproduces defects by running them. That reviewer has returned FAIL on nine
consecutive rounds of this branch, and every finding was real — including three that
defeated a fix I had already reported as verified, and two where my own claimed
evidence did not hold up when it was re-run.
A green suite is not the evidence here. Fourteen mutations are recorded against the
ACL work alone, each one proven to turn a specific test red and then restored: among
them, deleting the whole Windows delegation and deleting the POSIX
chmodeach left89 tests green across three files before the call edges were covered.
Summary by CodeRabbit