Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
164 commits
Select commit Hold shift + click to select a range
87ed409
docs(integrations): test the native-restore thesis against the code, …
lidge-jun Aug 3, 2026
535663c
docs(integrations): Desktop disable is buildable — standard mode is a…
lidge-jun Aug 3, 2026
13f48db
docs(integrations): name the two defects the toggle work actually has…
lidge-jun Aug 3, 2026
f375325
docs(integrations): re-plan the unit around desired state, not a roll…
lidge-jun Aug 3, 2026
98a6fb9
docs(integrations): write every implementation phase to diff level, r…
lidge-jun Aug 3, 2026
c8c213b
docs(integrations): close audit round 1 — accept all eleven, includin…
lidge-jun Aug 3, 2026
16792a6
docs(integrations): fold every accepted blocker back into the three f…
lidge-jun Aug 3, 2026
abb402e
docs(integrations): round 2 fails too — the phase map is the defect, …
lidge-jun Aug 3, 2026
9483d42
docs(integrations): re-slice the roadmap along ownership boundaries
lidge-jun Aug 3, 2026
06179a8
docs(integrations): fold round 3 into WP2 — unknown and incompatible …
lidge-jun Aug 3, 2026
1a76376
fix(clients): stop one rejected modality from poisoning a whole clien…
lidge-jun Aug 3, 2026
6c9ef8e
docs(integrations): WP2 evidence — A/B against gajae's own schema, no…
lidge-jun Aug 3, 2026
a0f05af
docs(integrations): fold the WP3 audit — a credential row must not sa…
lidge-jun Aug 3, 2026
ee361c8
docs(integrations): WP3 round 2 — replace client vocabulary instead o…
lidge-jun Aug 3, 2026
7b94c63
docs(integrations): WP3 round 3 — wire the fix instead of describing it
lidge-jun Aug 3, 2026
d463d45
docs(integrations): WP3 round 4 — one authoritative diff instead of t…
lidge-jun Aug 3, 2026
d492bec
docs(integrations): close the two residuals from the WP3 pass
lidge-jun Aug 3, 2026
a5e6cde
feat(integrations): API keys leave the client grid for their own row
lidge-jun Aug 3, 2026
feaac9c
docs(integrations): WP3 evidence — rendered, observed, and isolated f…
lidge-jun Aug 3, 2026
75d33a1
docs(integrations): re-scope WP4 to Codex alone
lidge-jun Aug 3, 2026
b8350d9
docs(integrations): close the WP4 audit — the re-scope held, Codex is…
lidge-jun Aug 3, 2026
5eaa71f
docs(integrations): give WP4 a lock where it had a check
lidge-jun Aug 3, 2026
c590714
docs(integrations): WP4 round 2 — the lock is not implementable over …
lidge-jun Aug 3, 2026
99ccfea
docs(substrate): research the four parts of a safe-interruption write…
lidge-jun Aug 4, 2026
7e67a8d
docs(substrate): lock the roadmap — four parts, dependency-ordered
lidge-jun Aug 4, 2026
6ab4d5f
docs(substrate): write all four implementation phases to diff level
lidge-jun Aug 4, 2026
2e8b27d
docs(substrate): a field report that proves two of this unit's criteria
lidge-jun Aug 4, 2026
3130356
docs(substrate): close audit round 1 — a contract phase, because four…
lidge-jun Aug 4, 2026
04f5ac7
docs(substrate): one owner for every surface four authors collided on
lidge-jun Aug 4, 2026
d7859bd
docs(substrate): round 2 — I declared ownership without transferring it
lidge-jun Aug 4, 2026
2d5e080
docs(substrate): make the contract complete, so it can actually be co…
lidge-jun Aug 4, 2026
ee18274
docs(substrate): collect the contract — four docs become consumers
lidge-jun Aug 4, 2026
47e7cac
docs(substrate): round 3 closes five, and names the one omission behi…
lidge-jun Aug 4, 2026
0f99666
docs(substrate): define what the contract referenced, and stop overcl…
lidge-jun Aug 4, 2026
bd1065a
docs(substrate): round 4 — a Critical that would have torn down the r…
lidge-jun Aug 4, 2026
a68b4d9
docs(substrate): move transition state into a real coordinator, and s…
lidge-jun Aug 4, 2026
3ec16ed
docs(substrate): every document's TypeScript now compiles, checked pe…
lidge-jun Aug 4, 2026
7bde9e0
docs(substrate): round 5 — the reviewer says the architecture is right
lidge-jun Aug 4, 2026
75aa9b5
docs(substrate): close all three architecture blockers
lidge-jun Aug 4, 2026
b97580f
feat(codex): the shared substrate contract, as types
lidge-jun Aug 4, 2026
5c176d7
feat(codex): the substrate's four owned modules
lidge-jun Aug 4, 2026
6b4a798
fix(codex): every history transition update was failing before its CAS
lidge-jun Aug 4, 2026
436e152
fix(codex): close the four C-phase findings against the landed substrate
lidge-jun Aug 4, 2026
96a90fd
feat(codex): the two WP8b primitives WP9 cannot land without
lidge-jun Aug 4, 2026
441f0bf
fix(codex): absence of a marker in config.toml was not native-clean
lidge-jun Aug 4, 2026
4c4dfed
feat(codex): the catalog seam's two primitives, as production exports
lidge-jun Aug 4, 2026
7277371
test(codex): six tests that passed under a broken substrate now fail
lidge-jun Aug 4, 2026
53e5152
fix(codex): the classifier read one catalog filename and demanded a s…
lidge-jun Aug 4, 2026
ed08c07
test(codex): five more mutations the strengthened tests still let thr…
lidge-jun Aug 4, 2026
d1cee04
fix(codex): a native-tagged history row is not proof the rollout was …
lidge-jun Aug 4, 2026
de15caf
fix(codex): the classifier parsed config.toml more strictly than prod…
lidge-jun Aug 4, 2026
63b03c6
docs(substrate): the catalog seam cannot borrow the native generation
lidge-jun Aug 4, 2026
db3d69e
test(codex): assert the property, not the example that happened to be…
lidge-jun Aug 4, 2026
e8674d6
docs(substrate): a source that was absent is still evidence
lidge-jun Aug 4, 2026
9d7d3f7
feat(codex): the source-evidence boundary the contract already promised
lidge-jun Aug 4, 2026
f51404f
test(codex): an allowlist of three aliases is not a property
lidge-jun Aug 4, 2026
baa7eae
docs(substrate): the writers we chose to keep were never excluded
lidge-jun Aug 4, 2026
07c68ba
feat(config): the generation guard WP9 is told to stop without
lidge-jun Aug 4, 2026
96c2761
test(codex): every path ended in .json, so a .json check looked general
lidge-jun Aug 4, 2026
4177345
test(restart): a deadline test that read the real clock expected it n…
lidge-jun Aug 4, 2026
364496f
docs(substrate): a lock around the write does not freshen the read
lidge-jun Aug 4, 2026
2db5ee9
docs(substrate): two requests that share a fetch do not share its aut…
lidge-jun Aug 4, 2026
1b32f82
feat(codex): K, and why an opaque permit type cannot hold a lock
lidge-jun Aug 4, 2026
e3e43cf
feat(config): looking at the generation should not create it
lidge-jun Aug 4, 2026
ea7970a
feat(codex): gather may look at the runtime, not run it
lidge-jun Aug 4, 2026
cdb0c8f
feat(oauth): preparing a catalog should not rotate the user's credent…
lidge-jun Aug 4, 2026
1923a2c
test(codex): a fail-fast lock has no guaranteed first-round winner
lidge-jun Aug 4, 2026
ba7083f
feat(codex): the catalog writers, behind a permit they must present f…
lidge-jun Aug 4, 2026
2becc77
feat(codex): one owner for every byte gather reads, and for every abs…
lidge-jun Aug 4, 2026
9f7e1a3
docs(substrate): the third cache with the same defect, and the owner …
lidge-jun Aug 4, 2026
471eb5f
docs(substrate): a test helper mutates the registry the flight key ig…
lidge-jun Aug 4, 2026
be1ec9b
docs(substrate): discovery policy is its own component, not a combo-g…
lidge-jun Aug 4, 2026
648fbdb
docs(substrate): every test asked whether B may join, none asked what…
lidge-jun Aug 4, 2026
cba2482
feat(codex): a shared fetch does not confer shared authority
lidge-jun Aug 4, 2026
8f403a1
feat(codex): the catalog writers we kept now queue behind the same lock
lidge-jun Aug 4, 2026
23a110d
feat(codex): the catalog seam, and the sixteen callers that now use it
lidge-jun Aug 4, 2026
5f76205
fix(codex): runtime authority can move without touching a single cata…
lidge-jun Aug 4, 2026
119a17b
test(codex): race the post-approval seam, and find it never commits
lidge-jun Aug 4, 2026
ecef67e
test(codex): the seam does commit — my fixture was the defect
lidge-jun Aug 4, 2026
d3882dd
fix(codex): a rotated key could publish rows the old key fetched
lidge-jun Aug 4, 2026
538f8b7
fix(codex): stop enumerating what may share a flight, and hash the wh…
lidge-jun Aug 4, 2026
57c2739
docs(codex): say why the credential term stays after the graph subsum…
lidge-jun Aug 4, 2026
45f7bb7
docs(substrate): WP10 was waiting on a convergeCodex that does not exist
lidge-jun Aug 4, 2026
b816851
docs(substrate): the split I chose left a window where a newer restor…
lidge-jun Aug 4, 2026
9e405e2
docs(substrate): the coordinator has no production caller, so no row …
lidge-jun Aug 4, 2026
8a8323f
docs(substrate): observing ENOENT is not the same as having created t…
lidge-jun Aug 4, 2026
c1bae49
docs(substrate): a finally block does not run when the process is killed
lidge-jun Aug 4, 2026
554b391
feat(codex): H gets its own database, keyed by the history it guards
lidge-jun Aug 4, 2026
3fd2399
feat(codex): H, held across the database, the manifest and the rollou…
lidge-jun Aug 4, 2026
5e72d35
feat(codex): the history mutation boundary, behind a permit checked a…
lidge-jun Aug 4, 2026
14cc0d4
feat(codex): the history unit, off the caller thread and behind one m…
lidge-jun Aug 4, 2026
37f3fd2
feat(codex): derive the history operation, dispatch it, and join the …
lidge-jun Aug 4, 2026
365707a
fix(codex): a coordinator created by the other process is not unsafe
lidge-jun Aug 4, 2026
e90989a
feat(codex): apply routes its history through the Worker instead of t…
lidge-jun Aug 4, 2026
e171518
feat(codex): restore takes the Worker too, except where the process i…
lidge-jun Aug 4, 2026
aaa17a8
feat(codex): the last two history callers stop writing on their own t…
lidge-jun Aug 4, 2026
86e5d67
test(codex): prove the history writer is unreachable, by walking imports
lidge-jun Aug 4, 2026
a358896
docs(codex): WP11 round 6 — the lock has no production caller, and I …
lidge-jun Aug 4, 2026
ad58656
docs(codex): merge WP11 into WP12 and rewrite the body, not just the …
lidge-jun Aug 4, 2026
fa610fd
fix(acl): the harden memo remembered a pathname, so a replaced file i…
lidge-jun Aug 4, 2026
d7f1a8e
docs(codex): another process can retarget the home symlink, so adjace…
lidge-jun Aug 4, 2026
1f83564
fix(acl): dev:ino is not identity on ext4, where an unlinked inode co…
lidge-jun Aug 4, 2026
d628e4b
fix(acl): reading the path after icacls answers the wrong question
lidge-jun Aug 4, 2026
5279155
docs(codex): label the round-6 findings as history, and strike the 'N…
lidge-jun Aug 4, 2026
296050e
fix(acl): my fix demanded that icacls not change permissions
lidge-jun Aug 4, 2026
cfa1f56
test(acl): the tests covered the twin, not the path production takes
lidge-jun Aug 5, 2026
ab9a397
docs(codex): the spec still described the implementation two fixes ago
lidge-jun Aug 5, 2026
9e40a8d
test(acl): the directory half was uncovered too, and that is four pro…
lidge-jun Aug 5, 2026
6d960c9
test(acl): 76 tests proved the primitive; nothing proved production s…
lidge-jun Aug 5, 2026
cc6f500
fix(codex): the POSIX chmod was swallowed, and nothing proved a claim…
lidge-jun Aug 5, 2026
1b86f0e
fix(codex): the owner had the same untested default, and threading wa…
lidge-jun Aug 5, 2026
d0675aa
test(codex): proving the hardener runs is not proving its failure mat…
lidge-jun Aug 5, 2026
955dd80
test(codex): a denied ACL is a refusal, not contention, and 'it rejec…
lidge-jun Aug 5, 2026
f3162b4
test(codex): a terminal snapshot cannot prove something never happened
lidge-jun Aug 5, 2026
53f0f81
test(codex): I wrote the rule and then broke it in the same commit
lidge-jun Aug 5, 2026
04f7ae4
test(codex): the plan said assert the exact target; the tests asserte…
lidge-jun Aug 5, 2026
a8b5b64
test(codex): the row that stayed empty longest was the successful one
lidge-jun Aug 5, 2026
fc8f480
test(acl): parameterizing one property over four entry points is not …
lidge-jun Aug 5, 2026
dfe1bb3
test(codex): 'after the ACL' has to mean after it finished
lidge-jun Aug 5, 2026
a6d2d62
test(acl): the retirement branch broke its own four-entry-point rule …
lidge-jun Aug 5, 2026
223e4c9
test(acl): optional does not mean unattributed
lidge-jun Aug 5, 2026
da74ae8
feat(codex): the switch now survives a restart
lidge-jun Aug 5, 2026
b3acb71
test(acl): closing the shortcut was not the same as closing the axis
lidge-jun Aug 5, 2026
1553b2d
feat(codex): the Codex card gets a switch, and turning it off keeps t…
lidge-jun Aug 5, 2026
d80a045
test(acl): 'how the memo can be wrong' was two axes, not one
lidge-jun Aug 5, 2026
10e6ad5
feat(grok): the toggle that already worked now survives a restart too
lidge-jun Aug 5, 2026
ea5c5a8
docs(codex): F4 closes at NEAR-PASS; widen the Windows gate and stop …
lidge-jun Aug 5, 2026
480eb3c
feat(codex): N exists — one bounded writer per canonical CODEX_HOME
lidge-jun Aug 5, 2026
5eedd2f
test(codex): C7/C18 — one OS user takes one lock however HOME is spelled
lidge-jun Aug 5, 2026
2ca521a
docs(codex): six audit rounds, and the plan that did not survive them
lidge-jun Aug 5, 2026
6545212
feat(codex): admission compares the bytes it actually read
lidge-jun Aug 5, 2026
b41900e
fix(codex): the journal fix that was only a comment
lidge-jun Aug 5, 2026
8d1f236
fix(codex): absence keeps the retryable projection it always had
lidge-jun Aug 5, 2026
e0459e4
docs(codex): the lock span is the IO, not the absence of it
lidge-jun Aug 5, 2026
872ea9e
test(codex): the admission fixtures still described the old contract
lidge-jun Aug 5, 2026
6361110
docs(codex): the ownership probe was asking the wrong question
lidge-jun Aug 5, 2026
ac7a25d
docs(codex): systemd has its own stale-definition signal, and it is n…
lidge-jun Aug 5, 2026
28f73f2
feat(codex): ownership stops being a hardcoded string
lidge-jun Aug 5, 2026
7405ba4
test(codex): the ownership guard had no test, and a mutation proved it
lidge-jun Aug 5, 2026
acca283
fix(service): one service-manager probe, not two
lidge-jun Aug 5, 2026
3dd5c27
wip: checkpoint before mutation checks
lidge-jun Aug 5, 2026
32d50cb
test(service): runLaunchctl carries its exit status now
lidge-jun Aug 5, 2026
cce005f
docs(codex): four rounds on one question - what does silence mean
lidge-jun Aug 5, 2026
9c58e7b
docs(codex): the snapshot could not answer even the question I claime…
lidge-jun Aug 5, 2026
afdd5a5
docs(codex): the non-gating snapshot does not typecheck as a design
lidge-jun Aug 5, 2026
ed64bce
docs(codex): the witness and the span disagreed about when bytes exist
lidge-jun Aug 5, 2026
f9a3f1b
docs(codex): the Worker does not derive its operation from intent, an…
lidge-jun Aug 5, 2026
b9ff9fd
docs(codex): the operation is derivable, so the schedule is honest af…
lidge-jun Aug 5, 2026
2e3f663
docs(codex): derivable is not persistable, and the row is never resol…
lidge-jun Aug 5, 2026
5740576
feat(codex): the write lock finally has a production caller
lidge-jun Aug 5, 2026
afffb02
docs(codex): migration, terminal CAS, and the two unions that do not …
lidge-jun Aug 5, 2026
65782cd
test(codex): the call edge, proven by two real processes contending
lidge-jun Aug 5, 2026
084b63f
docs(codex): the handoff I mentioned and the table I claimed both did…
lidge-jun Aug 5, 2026
667c7de
docs(codex): the table I wrote contradicted the contracts this unit a…
lidge-jun Aug 5, 2026
14cd838
fix(codex): declining to coordinate is not declining to write
lidge-jun Aug 5, 2026
9237f66
docs(codex): the restatement omitted three of 020's rows
lidge-jun Aug 5, 2026
4947e67
docs(codex): the boundary was three gaps, not one
lidge-jun Aug 5, 2026
0fe3472
docs(codex): the close handler must not learn to lie
lidge-jun Aug 5, 2026
d4701e0
docs(codex): what leaves this unit, and as what
lidge-jun Aug 5, 2026
bf021d7
merge: bring dev onto the write substrate branch
lidge-jun Aug 5, 2026
ab4fbf4
test(management): a dev test arrived using a dep this branch removed
lidge-jun Aug 5, 2026
2b9f36c
feat(codex): the transition is resolved, and a dead worker is not a s…
lidge-jun Aug 5, 2026
139fa25
fix(gui): the client id type never learned about codex
lidge-jun Aug 5, 2026
9bf41ad
test(gui): three overview tests assumed one card owned the only switch
lidge-jun Aug 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
187 changes: 118 additions & 69 deletions devlog/_plan/260803_codex_desktop_toggle/000_plan.md
Original file line number Diff line number Diff line change
@@ -1,81 +1,130 @@
# Codex and Claude Desktop toggles
# Client integration switches: Codex, Claude Desktop, and the memory they need

Split out of `260803_integrations_toggle_all` after its fourth audit
(`../260803_integrations_toggle_all/007_audit_synthesis_r4.md`). That unit keeps
Claude Code and Grok, whose toggles need no durable operation state. These two
need one, and it is the shared dependency four audit rounds kept circling.
Split out of `260803_integrations_toggle_all` after its fourth audit. That unit
shipped Claude Code and Grok. This one was scoped around a durable
operation-state engine — and a research cycle against the real code says that
engine solves the wrong problem.

## Why these two are together and separate
> **Re-planned 260803 after four research passes.** The rollback engine
> (`010_operation_state.md`, never written) is **dropped**. Evidence:
> `001`-`004`. The replacement is smaller, and it also fixes a defect in the
> toggle we shipped hours ago.

| | Codex | Claude Desktop |
|---|---|---|
| Artifacts | `config.toml`, `opencodex.config.toml`, model catalog, resume history (SQLite) | `<id>.json`, `.bak`, `_meta.json`, four fields in our config |
| Owner | us, mostly | **Claude Desktop** — we edit another app's registry |
| Prior state recoverable? | from its own journal, when hashes still match | only from `_meta.json` bytes; `appliedId` was never recorded |
| Removal code exists? | yes, `restoreNativeCodex` | **no** |
## The requirement in the owner's words

Neither can be undone by re-running its enable path, which is what makes Claude
Code and Grok cheap. Codex's enable is not the inverse of its disable — the
journal fallback means a disable may strip fragments rather than restore bytes,
and a later enable writes today's catalog, not yesterday's arrangement. Desktop
cannot re-derive which profile the user had selected at all.
> "스위치를 꺼도 프록시는 살아있어야 돼. 코덱스 말고 다른 것만 켜고 싶을 수도 있잖아."

So both need an operation record that outlives the request, and that record is
the first phase.
Turning a client off must leave the proxy running and serving every other
client. That is two obligations: the mutation must not stop the proxy, and the
OFF must survive a restart.

## What the research changed

| Prior claim | What the code says |
|---|---|
| Codex needs a durable operation-state engine | `ocx restore` already restores native Codex **without stopping the proxy** (`src/cli/help.ts:18`), and `ocx restore back` is the enable direction. Both exist. (`001`) |
| Desktop removal is impossible to do safely | Anthropic documents that a selected config without a valid `inferenceProvider` launches **standard mode**. We aim at that instead of guessing. (`002`) |
| The missing piece is crash recovery | The missing piece is **desired state**. Only Claude Code has one; Grok's shipped toggle is silently re-enabled by the next `ocx start`. (`003`) |

The fourth research doc (`004`) is an unrelated live defect found while looking:
one out-of-enum modality value makes a client reject its entire config. It joins
this unit as an independent work-phase (LOOP-UNIT-CHAIN-01).

## Read first

From the parent unit, all still authoritative:
In this unit, all four written this cycle:

- `001_native_restore_thesis.md` — Codex restore, its asymmetries, the history lock
- `002_desktop_standard_mode.md` — the official standard-mode contract, and why `default` is not the restore verb
- `003_durable_desired_state.md` — desired vs observed, the Grok regression, the schema
- `004_export_modality_poisoning.md` — the gjc/Pi enum defect

- `001_removal_path_inventory.md` — what each client's disable actually costs
- `003`, `004`, `005`, `007` — the four audit syntheses; `007` is why this unit
exists
- `002_consequence_dialog_ux.md` — dialog direction; the Codex and Desktop copy
lives there
From the parent unit, still authoritative: `001_removal_path_inventory.md`
(what each disable costs) and `002_consequence_dialog_ux.md` (dialog direction).
Its `007_audit_synthesis_r4.md` is the reason this unit exists, but its central
conclusion is now **superseded** by `001`-`003` here.

## Phases

| Phase | Doc | Deliverable |
|---|---|---|
| WP1 | `010_operation_state.md` | The durable operation record: a versioned discriminated journal entry, prepare/commit, restart reconciliation, a field-scoped config writer |
| WP2 | `020_codex_toggle.md` | Codex disable/enable on top of it |
| WP3 | `030_desktop_toggle.md` | Desktop removal + rollback, `appliedProfileId` schema work |

WP2 and WP3 are **parallel siblings**, not a sequence: Desktop does not depend on
Codex (audit r4 #11). Both depend on WP1 and nothing else.

## What WP1 must deliver

Named by audit round 4, findings #1, #2, #5, #3:

1. **A versioned discriminated journal entry.** Today's `JournalEntry`
(`src/integrations/journal.ts:34-51`) is file-shaped: one `configPath`, one
`SnapshotRef`, one `resultFingerprint`. A routing description and three
library members do not fit it. Needs `file-v1 | native-state-v1 |
desktop-hybrid-v1` with validated serialized state, per-member fingerprints,
and retention behavior.
2. **Prepare/commit with restart reconciliation.** A crash between the mutation
and the append leaves no undo state at all. Idempotent re-apply does not fix
this — it helps once a state exists, and none was recorded. The prepared
record must be durable before the mutation and resolved after.
3. **A field-scoped config writer.** `saveConfigPreservingClaudeCode` persists
the whole live object and its own docstring says a `providers` hand edit is
clobbered (`src/config.ts:2132-2135`); worse, when disk and caller both
changed `claudeCode`, the caller's stale subtree wins, so one toggle field can
clobber a concurrent Desktop-profile edit. Desktop's four fields need a write
that reloads from disk and touches only named paths.

## Carried-forward findings

Everything in `005` §Carried forward plus round 4's, in particular: Codex's
pre-state must capture the effective injection mode and history policy, not just
a routing kind (r4 #4); `injectCodexConfig` takes a concrete `catalogPath`, so
"selector" needs a resolver (r4 #4); Desktop's ordering is dangling-pointer-safe
but not transactionally crash-safe and must say so (r4 #6); auto-apply must be
suppressed while an operation is prepared (r4 #6).

## Status

Not started. `020` and `030` carry their pre-split content and are **stale**
against the operation-state design WP1 has not written yet; their next P
re-verifies them against it before any build.
**Re-sliced after audit round 2** (`006_audit_synthesis_r2.md`). The first map
sliced along a schema — one ten-key `clientIntegrations` map — which forced every
phase to touch every client's write path. Two audit rounds widened rather than
narrowed. This map slices along **ownership boundaries**: one client family per
phase, each independently auditable.

| Phase | Doc | Deliverable | Audit state |
|---|---|---|---|
| WP2 | `010_modality_boundary.md` | The client-dialect modality filter | **clean through two rounds** |
| WP3 | `020_api_keys_row.md` | API keys out of the card grid into their own row | never blocking |
| WP4 | `030_desired_state.md` → re-scoped | Desired state for **Codex only**, plus its gates, single-flight, ownership preflight and CLI semantics | re-scope pending |
| WP5 | `040_codex_toggle.md` | The Codex switch on WP4's flag | rewrite pending |
| WP6 | *(new doc)* | Grok's desired state, reusing the shape WP4 proved | not started |
| WP7 | `050_desktop_toggle.md` | The Desktop switch via documented standard mode | deferred behind WP5 |

The two pending docs were renamed so the decade order matches the phase order:
reading the unit lexicographically now gives the build order, which is the whole
point of the numbering convention.

WP1 was the research cycle plus this roadmap, twice audited.

WP2 and WP3 depend on nothing here and on each other not at all — they ship
first, alone, because each changes one thing at one boundary. That property is
the only thing that survived both audit rounds intact.

Three deliberate exclusions, each an accepted audit finding rather than a
convenience:

- **Claude Code's ingress gates are not touched at all.** Round 1 #1 established
that `claudeCode.enabled` is the documented kill switch for `/v1/messages`;
the honest conclusion is not to route it through a new helper in this unit.
- **The six file clients get no desired-state flag here.** Round 2 #4 (existing
explicit OFF choices are not migrated) and #5 (a mutating GET) both belong to a
phase that does not exist yet, and inventing it under audit pressure is what
produced round 2's new findings.
- **Desktop is behind Codex, not beside it.** Round 2 #3 found no coherent rule
when a foreign profile is selected. The goal explicitly permits an evidenced
deferral; this is one.

## Scope boundary

IN: `src/clients/config-export.ts`, `src/types.ts`, `src/config.ts`,
`src/codex/sync.ts`, `src/grok/sync.ts`, `src/cli/index.ts`,
`src/cli/opencode.ts`, `src/claude/desktop-3p.ts`,
`src/server/management/native-integration-routes.ts`,
`src/server/management/agent-settings-routes.ts`, `src/server/management-api.ts`,
`gui/src/pages/integrations/*`, `gui/src/styles-integrations.css`,
`gui/src/i18n/*`, `tests/`, `gui/tests/`.

OUT: releases, publishing, deploys, tags; starring the repository; rewriting the
six-client file machinery in `src/integrations/`; `docs-site` restructuring;
recording the previous `appliedId` (deferred, `002` §Residual).

## Criteria

- C1 — gjc loads our emitted config with no schema error, proven from the real
file; Pi's identical exposure is closed in the same change.
- C2 — Codex stays disabled across a proxy restart, an `ocx ensure`, and a
`POST /api/sync`.
- C3 — an upgrading user with no `clientIntegrations` key sees no behavior change.
- C4 — disabling Codex never stops the proxy and never closes `/v1/responses`.
- C5 — Codex toggles both directions from the overview with the proxy running.
- C6 — a Codex disable blocked by the held history DB is an explained refusal
naming the cause, never a raw 500 and never a false green.
- C7 — startup convergence never touches state owned by a service running from a
different `OPENCODEX_HOME` (round 2 #2, blocking).
- C8 — API keys render as a row above the grid, observed rendered.
- C9 — typecheck, full test, gui lint, gui test, privacy scan all green.
- C10 — Desktop ships with a proven restore path OR is deferred with recorded
evidence. Both outcomes close this criterion.

## Risk register

| Risk | Mitigation |
|---|---|
| A gate silently unplugs a working client on upgrade | Absent key means ON, everywhere, with a test for the absent-config case (`003`) |
| Gating a safety path | Explicit do-not-gate list: journal repair, ownership checks, owned teardown, shared transports (`003`) |
| Desktop pointed at a missing file | Never delete `appliedId`, never leave it dangling, never pick an entry by the name `Default` — this machine's `Default` is already dangling (`002`) |
| The modality fix erases valid internal metadata | Filter at the client-dialect boundary only; management and CLI keep carrying `audio` verbatim (`004`) |
| A green suite hides the real failure | 91 tests pass today beside a config gjc refuses to load. Every criterion names a live artifact, not a unit test |
| A repair introduces a worse defect than the one it fixes | Round 2 produced exactly this. One client family per phase, re-audited before the next starts — never a cross-client rewrite under audit pressure |
| Convergence tears down a foreign home's state | Every native remover runs `assertNativeTeardownOwned` before removal, and reconciliation runs only after service-ownership resolution (round 2 #2) |
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
# The native-restore thesis, tested against the code

Research doc. No diffs here — the implementation designs live in the decade
docs. This one records what the code actually does, because the previous
unit's plan was built on a claim the code does not support.

## The claim under test

`000_plan.md` asserts that Codex and Claude Desktop both need a durable
operation-state engine — a versioned discriminated journal entry, prepare/commit
with restart reconciliation, and a field-scoped config writer — before either
can get a toggle. The owner's counter-thesis: the proxy keeps RUNNING while a
client is switched back to its native path, so each client only needs to be
returned to a path that already exists, not replayed from a recorded snapshot.

For Codex the counter-thesis is correct, and the evidence is not subtle.

## Codex: the toggle pair already ships as a CLI

`ocx restore` is documented in `src/cli/help.ts:18-20` as:

> Restore native Codex config without stopping the proxy; `restore back`
> re-points codex at the running proxy.

That is the toggle, both directions, with the proxy up. `src/cli/index.ts:770`
calls `restoreNativeCodex()` with no lifecycle operation anywhere near it, and
`src/cli/index.ts:756` implements the enable direction as `syncModelsToCodex(live.port)`
against a proxy it first proves is live via `findLiveProxy()` (`:751`).

Stronger still: `POST /api/stop` (`src/server/management-api.ts:181`) calls
`restoreNativeCodex()` FIRST and only then schedules the drain and exit. The
restore therefore already executes while the listener is serving. Whether the
proxy later stops is irrelevant to the restore itself.

The service-stop path does verify the listener is gone before restoring
(`src/service.ts:2571`), but that check enforces the requested outcome "service
stopped" so success is not claimed while a supervisor respawns the process. It
is not a precondition inside `restoreNativeCodex()`.

**Conclusion:** no durable operation-state engine is required to make Codex
switchable. The disable path exists, the enable path exists, and both are
proxy-agnostic.

## What restore actually touches, and where it is not symmetric

Four state groups, when no external `model_provider` owns the config:

| Group | Restore behavior | Evidence |
|---|---|---|
| `config.toml` + `opencodex.config.toml` | Byte-exact from the journal when the injected hash still matches; otherwise strip owned fragments | `src/codex/journal.ts:109`, `src/codex/inject.ts:770` |
| Injection journal | Deleted on a complete restore, retained on a partial one | `src/codex/journal.ts:133` |
| Model catalog | Pristine backup + post-sync native additions, or drop slash-qualified routed rows keeping native ones | `src/codex/catalog/sync.ts:572-590` |
| Resume history | May update `state_5.sqlite`, patch/append rollout JSONL, consume the backup manifest | `src/codex/history-provider.ts:413,656,691` |

When an external provider such as `custom` owns `model_provider`, restore removes
only the stale journal and deliberately leaves everything else alone
(`src/codex/inject.ts:765`). That is a pre-existing courtesy to a user who moved
off us by hand, and the toggle must preserve it rather than "fixing" it.

Three asymmetries matter for the toggle's honesty:

1. **Enable is `syncModelsToCodex()`, not `injectCodexConfig()`.** Injection
selects and writes a catalog path but does not build the routed rows;
`src/codex/sync.ts:83-110` refreshes the catalog and then injects, and that
is what `ocx restore back` uses. A toggle wired to bare injection would
re-point Codex at a catalog that no longer lists the routed models.
2. **A post-injection root model selection is destroyed, not restored.** If the
user edited config after we injected, the hash no longer matches, the
fallback strip runs, and a root `model = "provider/slug"` line is removed
(`src/codex/inject.ts:315,700`). Re-enabling has no record of that selection.
The dialog copy must not promise to put it back.
3. **Resume history is reversible but not byte-identical.** Restore patches line
one when safe and appends a `session_meta`
(`src/codex/history-provider.ts:81,444`); re-enabling appends another
provider change rather than deleting that history.

## The history lock, and why the current return shape is not enough

The write path mirrors Codex's five-second SQLite busy timeout and retries twice
with a 500 ms delay (`src/codex/history-provider.ts:25,526`). A Codex app or IDE
holding the WAL writer lock is exactly what makes it fail.

At the low-level boundary the failure is structured:

```ts
return withHistoryRetry(...) ?? { rows: 0, files: 0, failed: true };
```

Recoverable busy/lock/permission failures return `failed: true`; corruption and
programming errors throw (`src/codex/history-provider.ts:511,536,577`).

But `restoreNativeCodex()` discards that structure. `history.failed` only edits
the message string, and `success` stays `cfg.success`
(`src/codex/inject.ts:787,794`). A config restore that succeeded while the
history stayed locked returns roughly:

```
{ success: true, message: "... history could NOT be restored ..." }
```

So a GUI that trusts `success` reports a clean disable while routed threads stay
tagged `opencodex` and remain invisible in the native app. And `failed: true`
itself conflates lock contention with `EPERM`/`EACCES`.

**Design consequence for `020`:** the toggle route must not consume
`restoreNativeCodex()`'s boolean. It needs the structured per-artifact outcome —
config, catalog, history — with the history failure carrying a classified reason,
so the card can render "disabled, but your routed threads are still hidden
because the Codex app is holding the history database" instead of a green check
or a raw 500. Parsing the message string is not acceptable.
Comment on lines +105 to +110

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Update the retired work-package reference.

Lines 105-110 point the structured Codex outcome at 020. That document is retired. The live roadmap assigns the Codex toggle to 040_codex_toggle.md.

The same numbering drift appears in 050_desktop_toggle.md, which calls itself WP6 and references 020_desired_state.md, while 000_plan.md assigns Desktop to WP7 and desired state to 030_desired_state.md. Align these references before implementation.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@devlog/_plan/260803_codex_desktop_toggle/001_native_restore_thesis.md` around
lines 105 - 110, Update the roadmap references in the affected planning
documents: replace the retired Codex work-package reference `020` with
`040_codex_toggle.md`, and align `050_desktop_toggle.md` with `000_plan.md` by
using WP7 for Desktop and `030_desired_state.md` for desired state. Preserve the
existing design guidance and implementation details.


## The real hazard is not the socket

The sibling explorer's central finding, and the one that reshapes this unit:
**there is no persisted per-client desired state.** After a disable, the next
proxy startup, `ocx ensure`, `ocx sync`, a provider/model mutation, or
`POST /api/sync` can silently re-inject Codex (`src/cli/index.ts:318,365`,
`src/server/management/config-routes.ts:261`).

That is the actual engineering work this unit needs — and it is a much smaller
thing than the operation-state engine `000_plan.md` specified. A durable
desired-state flag per client, defaulting ON so no existing setup dies on
upgrade, consulted by every automatic apply path. It is a switch's memory, not a
rollback engine.

`030` and the durable-state design pick this up; `010` is retired as specified
because the crash-recovery machinery it described is not what the evidence asks
for.

## Observable consequences of a Codex disable

What the consequence dialog must actually name:

- plain `codex` returns to its native provider path (`src/codex/inject.ts:688`)
- routed `provider/model` catalog rows disappear; native rows survive
(`src/codex/catalog/sync.ts:578,590`)
- the generated `opencodex` profile is removed or replaced, and managed native
subagent defaults are restored or stripped (`src/codex/inject.ts:703`)
- previously routed threads are retagged to their native provider — or stay
hidden if the history DB was locked (`src/codex/history-provider.ts:677`)
- the proxy keeps serving Claude, Grok, the exported file clients, and direct API
callers, because restore contains no lifecycle operation at all

That last line is the whole point of the feature.
Loading
Loading