Skip to content

The custody transition could not run against a real host - #159

Merged
ualtinok merged 7 commits into
cortexkit:mainfrom
iceteaSA:fix/custody-host-seams
Sep 18, 2026
Merged

ualtinok merged 7 commits into
cortexkit:mainfrom
iceteaSA:fix/custody-host-seams

Conversation

@iceteaSA

@iceteaSA iceteaSA commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

Four defects found by running /openai-account claustrum on a live machine tonight. Custody is now serving from the vault with all four applied; every one of them was invisible to the suite.

What happened

The command did nothing. Four attempts, no dialog, and — the part that cost the most — nothing in the log at all. I misdiagnosed that silence three separate ways (missing command registration, stale config, wrong verb form) before adding one unconditional log line at the top of command.execute.before. The next run gave the answer outright.

Silence was never evidence of where it stopped. It was evidence that nothing recorded anything.

The defects

1. client.auth.get does not exist. index.ts does input.client.auth as unknown as { all(); get(); set() }. That cast is an assertion, not a check. Captured live:

hostAuth surface {"keys":["_client"],"typeofAll":"undefined","typeofGet":"undefined","typeofSet":"function"}

Only set is real. The transition crashed 4ms in with U.get is not a function at the main-slot fingerprint capture. Fixed by preferring hostAuth.get when callable and falling back to the loader-captured getAuth, which is the plugin's actual read path for the main slot everywhere else.

2. client.auth.all does not exist either. Same cast, surfaced once fix 1 let the transition reach the host-slot write. all() backs only the torn-read guard, so it now reads $XDG_DATA_HOME/opencode/auth.json directly. That is more faithful than an SDK call, not less: the hazard being guarded is a partial read of that exact file, and Auth.set does unlocked read-modify-write on it. Any read or parse failure returns {}, which makes the guard refuse the write.

3. The mode was unreachable from local. custody-runtime.ts boot() returns early unless claustrum.mode === 'claustrum', so the cache is never connected — but the transition into claustrum has to probe the vault before any mode is written. Every participant preflighted vault-cold, forever. You could not enter the mode because entering required already being in it. Fixed with an ensureCache() on the runtime interface that uses the existing single-flighted connectCache().

Worth stating as a rule, because a sibling plugin shipped the same bug three weeks ago and fixed it only after a live incident: any resource a transition needs must be acquirable from the pre-transition state. Gate on "do I have a handle and a reason to try", never on "am I already there". Committing the mode flag last does not help — it makes this more likely, since the whole preflight runs while the flag still reads local.

4. Main quota dies under custody. Every poll cycle after the flip:

quota refresh failed {"accountId":"main","error":"custody tombstoned: claustrum-tombstone:v1:openai"}
wham usage fetch succeeded {"accountId":"cortex","status":200}
wham usage fetch succeeded {"accountId":"work-alt","status":200}

The quota path had no vault resolver for main — it reads the local token, finds the tombstone, calls refreshMainWithLease, and throws. Fallbacks were fine because resolveFallbackAccess is injected for them. Main's quota froze at its last pre-flip reading and would never have updated again.

This one is the worst of the four despite being the least dramatic. The other three crash or refuse — loud, immediate, blocking. This one serves traffic perfectly and silently stops updating one number that routing reads. Sticky-balanced placement weighs window pressure, so it would keep sending cold sessions against a stale figure until main hit a limit the plugin believed was 32% used.

Fixed with an optional resolveMainAccess on the quota deps, wired to the same resolver the request path uses. A refusal records a failed outcome and skips; it does not fall through to a local refresh, which would either throw on the tombstone or resurrect material the flip retired.

Why the suite passed through all four

Every existing test injects deps.auth and resolveFallbackVaultState. A suite that injects a seam cannot discover the seam does not exist. The tests proved the transition works against a host that was never there, and would have kept proving it.

The new tests deliberately do not inject those. They construct a client stub shaped like the real object — { _client: {}, set() {} }, no get, no all — and drive the real adapter. Mutation proofs, each reverting one fix alone:

(fail) main host slot > enters claustrum when the host auth client lacks get
(fail) main host slot > enters claustrum when the host auth client exposes only set
(fail) custody detection > connects the cache on demand while the store is local
(fail) refreshAllQuota > a tombstoned main uses the custody resolver without refreshing local auth

I re-ran the first mutation by hand against the merged branch to confirm it is not self-satisfying: restoring the bare cast call reddens three tests, including the malformed-auth-file case.

The logging

Included deliberately rather than as debug leftovers. It is what turned four silent failures into a one-line diagnosis: an unconditional first-line hook log whose absence proves the host never dispatched; the parsed command tokens; the typeof of every injected dep; the participant list and per-participant handle presence; and the transition's finish line carrying status, reason and the per-account outcomes map — an aborted barrier writes nothing to disk, so without that line a refusal and a no-op are indistinguishable afterwards.

Gates from the repo root: build, format:check, lint, types, test all pass — core, 1364 OpenCode, 14 Pi.


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.


Summary by cubic

Fixes custody transition so it runs against a real host by replacing assumed auth client methods with fallbacks, connecting the cache before preflight, and routing main quota reads through custody. Adds logging that surfaces silent failures and tests that drive the real adapter without injecting seams.

Bug Fixes

  • client.auth.get and all do not exist on the host; fallbacks to the loader-captured auth and a direct auth.json read replace the casted calls.
  • Preflight now connects the cache via ensureCache() when the mode is still local, so entering claustrum no longer blocks on the wrong mode.
  • Main quota refresh resolves custodied tokens through a new resolveMainAccess dep, preventing tombstoned main from freezing at a stale reading.
  • Added first-line command hook and transition logging so future failures are diagnosable from logs alone.

Testing

  • New tests construct a client stub with only set and drive the real adapter, proving the transition works against a host missing assumed methods.

Written for commit 0024a51. Summary will update on new commits.

Review in cubic

@ualtinok
ualtinok merged commit f480e17 into cortexkit:main Sep 18, 2026
5 checks passed
@ualtinok

Copy link
Copy Markdown
Contributor

Merged as f480e17. Four real defects, and the way you found them is the point: every one was invisible to a suite that injects the seam it needed to test.

I verified the two structural claims on main before merging rather than from the description. index.ts:1160 does cast input.client.auth to a shape with all/get/set with no check, and refresh-all-quota.ts had no main resolver at all — so both defects were exactly where you said.

Mutation-verified two myself. Restoring the bare hostAuth.get call reddens three, including the malformed-auth-file case, which is the one that shows the fallback is not merely present but load-bearing. Disabling the main resolver at its call site reddens a tombstoned main uses the custody resolver without refreshing local auth. My first attempt at that second mutation edited the type declaration instead of the call and passed — a reminder that a green mutation run proves nothing until you have checked the mutation actually changed behaviour.

Defect 4 is the one I would have missed in review and it is the one I am most glad you caught. The other three fail loudly. That one serves traffic perfectly while a number routing depends on quietly stops moving, and sticky placement keeps weighing a figure that was true an hour ago. There is no symptom to notice until an account hits a limit the plugin believed was a third used.

Your rule from defect 3 is the one worth carrying beyond this repo: any resource a transition needs must be acquirable from the state before the transition. Gating on "am I already there" rather than "do I have a handle and a reason to try" makes the mode unreachable, and committing the flag last makes it more likely rather than less, since the whole preflight runs while the flag still reads the old value.

The logging stays, and I would have asked for it if you had not included it. An unconditional first-line hook log whose absence proves the host never dispatched is the difference between a one-line diagnosis and the three wrong guesses you made first. An aborted barrier writes nothing to disk, so without the finish line a refusal and a no-op are indistinguishable afterwards — that is not debug leftovers, it is the only witness.

Gate on the merge: core 147, opencode 1364, pi 14, typecheck and biome clean.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants