Skip to content

feat(codex): a write substrate that survives being interrupted - #998

Merged
lidge-jun merged 164 commits into
devfrom
codex/260803-integration-switches
Aug 5, 2026
Merged

feat(codex): a write substrate that survives being interrupted#998
lidge-jun merged 164 commits into
devfrom
codex/260803-integration-switches

Conversation

@lidge-jun

@lidge-jun lidge-jun commented Aug 4, 2026

Copy link
Copy Markdown
Owner

What this is

The Codex integration needed an ON/OFF switch. The switch itself turned out to be
cheap — ocx restore already returns Codex to its native path without stopping the
proxy. 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:

  • gjc/Pi modality fix — an audio entry in inputModalities poisoned entire
    client configs, so /provider listed neither opencodex nor pi. Fixed at the
    client-dialect boundary in src/clients/config-export.ts and proven A/B against
    gajae's real ModelsConfigSchema.
  • API keys as their own row — keys left the integrations card grid for a
    full-width row, and loadApiKeyCount now throws instead of collapsing a failure
    into 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/sync projection.

Design points that took several audit rounds and are load-bearing:

  • Transition state lives in a SQLite coordinator, not JSON — a read/compare/
    replace across two coordinators is not a CAS, and an old worker could replace
    N+1 with a stale N.
  • Namespacing keys on uid/SID, because on Bun 1.3.14 both os.homedir() and
    os.userInfo().homedir return an environment-controlled home while uid does not.
  • The native generation identifies a routing transition. Catalog bytes, backups
    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}) is SQLITE_MISUSE on
Bun 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.toml and treated every other routed byte as
absent, so a real generated profile classified as clean and the coordinator
installed a fresh {0,null} over the only evidence an interrupted transition
needed salvage. Separately, a threads row tagged openai whose rollout still
said opencodex also classified as clean — reachable, not hypothetical, because
restoreNativeHistory swallows a failed rollout write and then marks every row
native 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 only ENOENT
counts as a clean surface.

Verification

bun run typecheck clean, bun run test green (8100+ tests across 535 files),
bun run privacy:scan passed, bun run lint:gui clean. The prepush hook reruns the
full 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:

  • A catalog-only commit cannot publish a native generation at all: the coordinator
    schema requires every positive generation to carry a history schedule, so doing so
    would have meant scheduling history work that does not exist.
  • Parent and inode identity does not detect an in-place rewrite, so sources carry
    content evidence — including for sources that were checked and found absent,
    since absence is what selects the default catalog path.
  • The four first-party catalog writers this phase deliberately keeps were never
    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 the
one 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.toml and recorded nothing, so
ocx start called syncGrokConfig unconditionally 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 clientIntegrations map, one key per
client. Absence means ON, and that rule carries the weight: a config written
by an older binary, an untouched one, and an explicit true are the same state,
and reading any of them as OFF would silently unroute someone who never asked.
Only an explicit false is off, so a hand-edited "false" string degrades to ON
rather than to OFF.

Turning a client off is not ocx stop. The proxy keeps serving, other
clients 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 handleStart into testable functions. They had
been 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 and
inverted, the sync failure no longer swallowed, the port not forwarded, intent
never persisted, a non-boolean enabled accepted, the in-flight guard never
cleared, 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:

  • Codex — a real toggle on its card and a PUT /api/native-integrations/codex
    route. Turning it off returns Codex to its native path and leaves the proxy
    up
    , so the other clients keep routing.
  • Grok — the toggle already existed and was silently re-enabled by the next
    ocx start. It now survives a restart.
  • API keys — out of the card grid and into their own full-width row.
  • Claude Desktop — not shipped. Its row still carries toggle: null, pending
    the documented standard-mode restore path.

Durable intent lives in one clientIntegrations map in the config, read by the
lifecycle 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 AdmissionSnapshot producer and
the inject.ts synchronous-native / awaited-history split — arrive there too. A
standalone 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.ts does not exist yet. Until it does, the switches
above 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.
hardenStableLockFile hardens 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:ino alone — 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.
  • comparing the full identity across the ACL call — icacls changes permissions and
    a permission change moves ctime, so this rejected its own successful work and
    would have failed closed on the first harden of every path on Windows.
  • an unconditional swallowing chmod — on POSIX the mode is the whole mechanism and
    there 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) and
freshness (ctimeNs, stored from the post-harden read and deliberately not
compared across it).

Remaining activation gate: the NTFS bigint inode behaviour is unverified. It is
marked 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 --noEmit clean, bun run privacy:scan passed, bun run lint:gui clean.
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 chmod each left
89 tests green across three files before the call edges were covered.

Summary by CodeRabbit

  • New Features
    • Added a dedicated API keys row with checking, unavailable, and settled states, plus a “Manage keys” action.
    • Improved Codex integration toggling, synchronization feedback, and recovery behavior.
  • Bug Fixes
    • Prevented unsupported audio modalities from producing invalid Pi and Gajae model configurations.
    • API-key loading errors now display an unavailable state instead of appearing as zero issued keys.
  • Localization
    • Updated integration status and API-key management text across supported languages.

…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.
@lidge-jun
lidge-jun merged commit 4070464 into dev Aug 5, 2026
23 checks passed
@lidge-jun
lidge-jun deleted the codex/260803-integration-switches branch August 5, 2026 09:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant