Skip to content

feat(custody): global claustrum|local mode with main-account vault takeover - #196

Open
iceteaSA wants to merge 24 commits into
cortexkit:mainfrom
iceteaSA:feat/custody-toggle
Open

feat(custody): global claustrum|local mode with main-account vault takeover#196
iceteaSA wants to merge 24 commits into
cortexkit:mainfrom
iceteaSA:feat/custody-toggle

Conversation

@iceteaSA

@iceteaSA iceteaSA commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds one global custody mode with two commands:

/claude-account claustrum
/claude-account local

Bare /claude-account reports status. The TUI exposes the same single mode control.

Under claustrum, every enabled OAuth route, including main, is served from the Claustrum vault through a handle manifest written by Claustrum tooling. The plugin never runs ck, imports or migrates credentials, or writes the host auth.json slot.

Main must already be onboarded into the vault with Claustrum's tooling before claustrum is entered; the takeover preflights every account and changes nothing if any account refuses. All refusals are listed.

Dependency (blocks activation of the main flip, not review): the dedicated Claustrum import verb for main (migrate-plugin with an operator gate for the main label) is not in any deployed or in-flight Claustrum build as of 2026-09-07 — it is queued on the Claustrum side behind their PR #33. Until it lands, main onboarding follows Claustrum's runbook; fallback-only custody does not depend on it.

Stacked on #198

Branch: feat/custody-toggle · head 5d42c6c · 20 commits plus the #198 merge.

This is stacked on PR #198 (feat/feed-account-uuid), merged at 6b5d9fc. GitHub cannot base one fork branch on another, so the base displays as main. Review this diff against #198's head 82105d3: 55 files, +18,955/−3,968.

Implemented against docs/custody-state-machine.md per the go-ahead. Integration, activation, migration, and release stay with the maintainer.

Behaviour

Mode

Fallback sidecars are tombstoned by takeover:
{type:"oauth", access:"", refresh:"claustrum-tombstone:v1:anthropic", expires:0}.
Empty access fails the vault sealer's shape gate by construction. Tombstones use a write/recognise/refuse split, with refusal ⊋ recognition pinned as one assertion. The refuse guard is in the innermost header builders: quota poll, prewarm, prime, profile, and send.

Mode is committed last, with byte-identical rollback at every phase.

Serving

• Cold vault at boot is typed FAIL_CLOSED: all OAuth routes hold until the next viable boot. A healthy fallback is held too; whether a per-handle cold main should instead degrade to fallback-only serving remains open in §12.4.
• A main that goes cold after a warm boot returns typed claustrum_main_unavailable. There is no sidecar fallback and no tombstone bearer.
• Cold fallback is excluded per request.
• A vault credential whose account_id mismatches persisted identity is refused. Main without identity evidence projects unknown-identity.

Exit

local leaves main in interactive re-login. A fallback's manifest binding clears only after a plugin-owned login completes and new material is observed. A stale in-flight refresh cannot resurrect real material over a tombstone. Re-entering claustrum for that re-logged label refuses binding_missing until a fresh operator import under --replace.

Fresh install

A rostered OAuth account with no local material and a resolved binding is vault-owned. Loader, classifier, routing admission, and quota policy all use the same hasNoLocalCredential predicate. A quota- or killswitch-exhausted vault row is still excluded.

401s

A vault-served 401 reports report_auth_failure with the served record version and reporter_source. The report is suppressed when the resident cache has already advanced past that served version. Sidecar-served 401s never report.

Commit shape

  • feat(custody): per-account custody toggle and the global-mode custody state machine spec
  • feat(custody): Claustrum handle-manifest layer with the cross-tenant lock
  • feat(custody): global claustrum mode with one locked writer; retire the per-account handler
  • feat(custody): tombstone write/recognise/refuse split, login refusal under custody, TUI mode control
  • feat(custody): vault-only routing under claustrum with the cold-route policy and the identity fence
  • feat(custody): main vault 401 reports are fenced to the send-time record version
  • feat(identity): bind main quota identity to the vault credential; never brand the slot key as a provider uuid
  • feat(custody): the local exit clears a binding only after a verified re-login through the plugin
  • feat(custody): takeover coordinator, live adapters, two-phase boot barrier, generic vault-main route, structural gate
  • feat(custody): takeover writes with a mode-last commit, staged rollback, and an all-refusals preflight
  • fix(custody): review hardening — rollback order, tombstone bearer, mode-write capability, manifest hygiene, identity attribution
  • test(custody): whole-reach host-write invariant, route-local fallback residency, cold-tick recovery
  • fix(custody): fence in-flight refreshes and stale credential writes against the takeover
  • refactor(custody): extract custody dimensions and takeover orchestration out of the plugin entry
  • Merge feat/feed-account-uuid (PR feat(feed): publish the Anthropic account uuid on every entry; reap stale leases #198) into feat/custody-toggle
  • test(custody): hermetic first-run acceptance suite; a rostered account with no local material is vault-owned when bound
  • fix(custody): a throwing post-commit verifier reverts the mode; transient manifest refusals stay pending
  • test(e2e): custody mode through a real OpenCode process against a fake Claustrum daemon
  • fix(custody): quota policy on empty vault rows, serialized local exit, identity projection, tombstone metadata freshness
  • docs: global Claustrum custody for operators; define the matrix axes
  • fix(custody): e2e daemon strictness, quota pass under verification, store creation before locking; one missing-credential predicate

Verification

At fce9a0c (tree byte-identical to the squashed history; final head 5d42c6c adds the verb-neutral onboarding text and the cross-process quota-poll fence): opencode 1790/0 · core 177/0 · e2e 35 pass / 1 todo / 0 fail · typecheck 0 · lint and format clean.

check:claustrum-golden is IDENTICAL (0e9dee7). Pack dry-runs include custody modules and exclude tests, golden files, and captures. aft_inspect reports 0 diagnostics, dead code, unused exports, and cycles.

New coverage includes the 36-row reconcile matrix and takeover rollback, hermetic fresh-install coverage (10 tests, 5 scenarios), and a real opencode run process against a fake Claustrum daemon on the subc wire (packages/e2e-tests/src/mock-claustrum.ts), covering vault-served main and fallback, boot-cold hold, and served-version 401 reporting.

Every production fix carries a red-first test and a named production mutation. Remaining open review threads are P3 test-hygiene items, declined; the two feed-schema threads are moot after the #198 merge and closed.

Merge-order dependencies

Open questions

• §12.4: should a per-handle cold MAIN at boot degrade to fallback-only serving instead of the global hold? Not decided here.
• The late-cold main e2e row is a test.todo: a real process has no clock seam to expire the resident cache deterministically.
cortexkit/claustrum#37: credential_id is omitted from credential.get by design; identity comparison relies on account_id.
• The main import verb on the Claustrum side (see Dependency above) — this PR names no verb; the exact command gets pinned in the README once it ships.

Authoritative docs: docs/custody-state-machine.md defines every matrix letter in §3. README custody sections now use global-mode vocabulary only.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

2 issues found across 17 files

Confidence score: 3/5

  • packages/opencode/src/sidebar-state.ts stores custodyState without rendering it, leaving custody off, vault-served, and cold states invisible in the sidebar; pass the state into the account renderer and display it consistently.
  • packages/opencode/src/sidebar-state.ts preserves unknown custody values because the cast provides no runtime validation, which can expose invalid normalized state; validate against the four allowed literals and omit invalid values.
Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/opencode/src/sidebar-state.ts">

<violation number="1" location="packages/opencode/src/sidebar-state.ts:345">
P2: The sidebar stores `custodyState` but never renders it, so custody off, vault-served, and cold states remain invisible in the sidebar. Pass this state into the sidebar account renderer and display it consistently with the account dialog.</violation>

<violation number="2" location="packages/opencode/src/sidebar-state.ts:345">
P2: When the sidebar state contains an unknown custody value, this branch preserves it because the cast does not validate at runtime. Validate against the four allowed literals and omit invalid values so normalized state remains within `SidebarAccountState` and fails closed for future or corrupt payloads.</violation>
</file>
Architecture diagram
sequenceDiagram
    participant User as User
    participant OpenCode as OpenCode Command Runtime
    participant AccountCmd as Account Command Handler
    participant Manager as Fallback Account Manager
    participant Config as Account Config and State Files
    participant ConfigLock as Config Write Lock
    participant RefreshLock as Per-Account Refresh Lock
    participant Cache as Claustrum Credential Cache
    participant Claustrum as Claustrum Service
    participant Tick as Background Refresh Loop
    participant UI as Sidebar and Account Dialog
    participant Pi as Pi Command Runtime

    Note over User,Claustrum: OpenCode fallback-account custody control

    User->>OpenCode: /claude-account custody id on or off
    OpenCode->>AccountCmd: Parse and validate custody action
    alt Main account
        AccountCmd-->>OpenCode: Reject - main account custody is not changeable
    else Non-OAuth or disabled fallback
        AccountCmd-->>OpenCode: Reject - ineligible account or disabled account
    else OpenCode OAuth fallback
        AccountCmd->>Manager: Execute custody transition
        alt custody on
            Manager->>Config: Read account custody handle
            alt Handle missing
                Manager-->>OpenCode: Refuse without contacting Claustrum
            else Handle present
                Manager->>Claustrum: Detect configured Claustrum connection
                alt Claustrum unavailable
                    Manager-->>OpenCode: Refuse - config remains unchanged
                else Claustrum available
                    Manager->>RefreshLock: Acquire account refresh lock
                    alt Lock unavailable
                        Manager-->>OpenCode: Refuse - config remains unchanged
                    else Lock held
                        Manager->>Manager: Mark custody verification in progress
                        Tick->>Manager: Background refresh tick
                        Manager-->>Tick: Skip account while verification lock is held
                        Manager->>Cache: credential.get(handle), max 15 seconds
                        Cache->>Claustrum: credential.get(handle)
                        Claustrum-->>Cache: Usable or unusable credential
                        alt Credential usable at command clock
                            Cache-->>Manager: Credential
                            Manager->>ConfigLock: Acquire config write lock
                            ConfigLock->>Config: Load, set account enabled, save atomically
                            Config-->>ConfigLock: Persisted
                            ConfigLock-->>Manager: Gate enabled
                            Manager->>RefreshLock: Release account refresh lock
                            Manager-->>OpenCode: Custody on - vault-served
                        else Timeout, reauth, or vault failure
                            Cache-->>Manager: Verification error
                            Manager->>RefreshLock: Release account refresh lock
                            Manager-->>OpenCode: Refuse - gate remains unchanged
                        end
                    end
                end
            end
        else custody off
            Manager->>Manager: Bump per-account gate generation
            Manager->>ConfigLock: Acquire config write lock
            ConfigLock->>Config: Load, clear custody gate, save atomically
            Config-->>ConfigLock: Persisted
            ConfigLock-->>Manager: Gate disabled
            Manager->>Cache: Invalidate resident credential
            Manager->>Cache: Fence late in-flight gets by generation
            Manager-->>OpenCode: Custody off - plugin-served
        end
    end

    Note over Cache,Manager: Generation checks prevent startup warm, timed-out verification, and background gets from repopulating cache after custody off

    OpenCode->>Manager: Build account status and RPC projection
    Manager->>Config: Read account and custody state
    Manager->>Cache: Check resident vault credential
    Cache-->>Manager: Served, reauth, or cold
    Manager-->>OpenCode: Allowlisted fields only - handle and token excluded
    OpenCode->>UI: Sidebar and dialog payload
    UI->>UI: Normalize new fields and tolerate older payloads
    UI-->>User: custody off, on - vault-served, on - vault reauth, or on - cold

    User->>Pi: /claude-account custody id on or off
    Pi->>AccountCmd: Execute with unsupported custody capability
    AccountCmd-->>Pi: Custody is OpenCode-only in this version
    Pi-->>User: Refusal - no config or state persistence
Loading

Tip: cubic can generate docs of your entire codebase and keep them up to date. Try it here.

Re-trigger cubic

Comment thread packages/core/src/claustrum.ts
Comment thread packages/opencode/src/index.ts Outdated
Comment thread packages/core/src/accounts.ts Outdated
Comment thread packages/core/src/claustrum.ts
Comment on lines +345 to +348
...(typeof entry.custodyState === 'string' && {
custodyState:
entry.custodyState as SidebarAccountState['custodyState'],
}),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: When the sidebar state contains an unknown custody value, this branch preserves it because the cast does not validate at runtime. Validate against the four allowed literals and omit invalid values so normalized state remains within SidebarAccountState and fails closed for future or corrupt payloads.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/opencode/src/sidebar-state.ts, line 345:

<comment>When the sidebar state contains an unknown custody value, this branch preserves it because the cast does not validate at runtime. Validate against the four allowed literals and omit invalid values so normalized state remains within `SidebarAccountState` and fails closed for future or corrupt payloads.</comment>

<file context>
@@ -338,6 +341,11 @@ export function normalizeSidebarState(raw: unknown): SidebarState {
             typeof entry.needsReauth === 'boolean' ? entry.needsReauth : false,
           ...(entry.vaultReauth === true && { vaultReauth: true }),
+          ...(entry.vaultServed === true && { vaultServed: true }),
+          ...(typeof entry.custodyState === 'string' && {
+            custodyState:
+              entry.custodyState as SidebarAccountState['custodyState'],
</file context>
Suggested change
...(typeof entry.custodyState === 'string' && {
custodyState:
entry.custodyState as SidebarAccountState['custodyState'],
}),
...((entry.custodyState === 'off' ||
entry.custodyState === 'on-vault-served' ||
entry.custodyState === 'on-vault-reauth' ||
entry.custodyState === 'on-cold') && {
custodyState: entry.custodyState,
}),

Comment thread packages/core/src/accounts.ts
Comment thread packages/core/src/accounts.ts
Comment thread packages/core/src/commands/account.ts Outdated
typeof entry.needsReauth === 'boolean' ? entry.needsReauth : false,
...(entry.vaultReauth === true && { vaultReauth: true }),
...(entry.vaultServed === true && { vaultServed: true }),
...(typeof entry.custodyState === 'string' && {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: The sidebar stores custodyState but never renders it, so custody off, vault-served, and cold states remain invisible in the sidebar. Pass this state into the sidebar account renderer and display it consistently with the account dialog.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/opencode/src/sidebar-state.ts, line 345:

<comment>The sidebar stores `custodyState` but never renders it, so custody off, vault-served, and cold states remain invisible in the sidebar. Pass this state into the sidebar account renderer and display it consistently with the account dialog.</comment>

<file context>
@@ -338,6 +341,11 @@ export function normalizeSidebarState(raw: unknown): SidebarState {
             typeof entry.needsReauth === 'boolean' ? entry.needsReauth : false,
           ...(entry.vaultReauth === true && { vaultReauth: true }),
+          ...(entry.vaultServed === true && { vaultServed: true }),
+          ...(typeof entry.custodyState === 'string' && {
+            custodyState:
+              entry.custodyState as SidebarAccountState['custodyState'],
</file context>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Superseded: the per-account custody <id> on|off handler this thread reviewed was removed in aebb09d (global claustrum|local mode replaces it; see docs/custody-state-machine.md §1/§8). Resolving.

Comment thread packages/opencode/src/tests/index.test.ts
@iceteaSA

iceteaSA commented Sep 3, 2026

Copy link
Copy Markdown
Contributor Author

Agreed: converge on one switch. Two constraints matter:

"All or nothing" cannot include main yet. Main uses OpenCode's auth.json slot, and vault custody there is frozen upstream (claustrum#31, mirror-only). Until the main-takeover plugin lands, a global switch means fallbacks use the vault while main stays local.

No connection string is needed. The subc connection file is discovered at /run/user/<uid>/subc-connection.json. Per-account membership belongs in Claustrum's handle manifest, ~/.config/cortexkit/opencode-handles.json, in our provider: anthropic / serve: anthropic-auth block. An account listed there is vault-served; otherwise it is local. The next PR carries that reader/resolver/writer, plus the cross-tenant lock convention agreed with Claustrum, unchanged by this decision.

The shape:

  • claustrum.enabled: true|false is the only config gate. Membership comes from the manifest. The plugin verifies entries at startup and on each custody tick, then reports status.
  • /claude-account custody <id> on becomes an enroll verb, not a toggle: verify the handle against the vault, write the manifest entry, and drop the local refresh token (C2). A bare config flag cannot perform the fail-closed verification that closed incidents 1–3. off removes the entry; recovery is ck auth login locally.
  • If the vault is cold, exclude the account from routing candidates. No sidecar ladder, consistent with dropping the refresh token.

This removes the PR's claustrum.accounts[<id>].enabled surface. Better to change it before merge than after. If this works, I'll rework #196 around the gate and enroll verb, then rebase the manifest PR.

@ualtinok

ualtinok commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

One change to the agreed redesign: Claustrum mode should serve the main Anthropic account too, rather than leaving main permanently local.

I verified the OpenCode seam directly on 1.18.26 with an isolated live probe. A structurally valid but expired, non-secret OAuth tombstone in OpenCode's anthropic auth slot still caused auth.loader to run, kept Anthropic in the provider catalog, and routed a model request through the plugin-provided custom fetch. OpenCode does not need a usable access or refresh token in that slot before invoking our loader. This gives us a clean takeover path:

  • OpenCode auth.json: fixed non-secret tombstone only
  • Claustrum: the real main access/refresh credential, vault-owned
  • anthropic-auth: recognizes the tombstone, serves main from its resident Claustrum credential cache, and handles refresh plus provenance-bound 401 reporting through Claustrum

The existing tombstone branch in the plugin already anticipates this path; it currently fails closed pending takeover implementation.

Also, claustrum#31 is no longer a blocker. Q6 was decided and the issue is closed: leaving Claustrum mode after main migration requires interactive re-login; no credential export/automatic rollback will be built.

Requested command contract

For the custody mode itself, I think the complete surface should be:

/claude-account claustrum
/claude-account local

A bare /claude-account should continue to show status, and the existing account-management actions (enable, disable, remove, login flows, etc.) remain. What we do not need is custody <id> on|off, per-account custody gates, or another on/off vocabulary.

  • claustrum means every enabled OAuth route, including main, is Claustrum-served. Manifest entries are credential bindings/prerequisites, not independent mode toggles.
  • Before switching, verify the main and enabled fallback manifest entries and usable vault credentials. If preflight or migration fails, leave local mode and local credentials unchanged.
  • The transition must be race-safe against refresh: hold the required cross-process migration/refresh locks through the handoff so a concurrent local refresh cannot resurrect a local refresh token after the drop.
  • Commit the handoff by removing local fallback refresh material and replacing main's host credential with the non-secret tombstone. In Claustrum mode there is no sidecar credential fallback.
  • A cold/missing fallback is excluded from routing. A cold/missing main produces an explicit local provider-unavailable error; it must never fall through to tombstone refresh or transport.
  • local exits Claustrum serving. For a migrated main this intentionally ends in interactive re-login, per claustrum#31; it must not imply that the vault can export the refresh credential.

This makes the mode truthful and avoids the surprising mixed state where a global claustrum.enabled setting still leaves the highest-value credential in OpenCode. Please rework #196 around this global mode and main takeover rather than the current fallback-only per-account command.

@iceteaSA

iceteaSA commented Sep 4, 2026

Copy link
Copy Markdown
Contributor Author

Taking the ruling — reworking around the global mode and main takeover. Four things the rework needs decided, three of them consequences of the takeover that the contract doesn't cover yet. Two come from the openai-auth seat matching this contract, one from a live probe on the vault side.

1. A prerequisite that must gate the tombstone write. The Claustrum side proved on a scratch vault today that writing main's tombstone into auth.json is currently destructive. Their live re-seal automation gates on shape only (object, non-empty access), so the tombstone satisfies it and gets imported over the live vault record — verified, replaced oauth:anthropic, record reads active afterwards. The reserved-prefix refusal that would reject it exists only on their unmerged branch, not on the deployed binary. If that import lands while our side has just dropped the local copy, both copies of main's family are gone and recovery is interactive re-login only.

So the migration task carries a hard prerequisite: their refusal ships and their auth.json-watching instruments are re-pointed at the vault before any tombstone touches the file. I checked whether we could sidestep it by shaping the tombstone to fail their gate while still satisfying OpenCode's loader — we can't; anything that fails their check fails OpenCode's too.

2. Main's import can't be driven by our verb. The vault side wants main's import unlocked by an operator-typed flag on the CLI invocation rather than a field in the export file, on the grounds that a file one process writes and another reads must never carry permission. That's right, and it means /claude-account claustrum cannot perform main's import itself — a plugin shelling out to a CLI with a permission flag defeats the point. Proposed shape: the operator imports main first, then runs the mode verb; the verb's preflight verifies main already has a manifest entry and a usable vault credential and refuses the flip otherwise. Authority stays with the human, and we can only commit a transition whose prerequisites already exist.

3. Where the mode persists. A field only the verb writes, not documented as hand-editable, is my reading. I'd like it to be non-load-bearing as well: whether an account is actually vault-served stays determined by the credential state (manifest entry plus dropped local material / tombstone), so a hand-edited or restored-from-backup mode field can't make the plugin serve an account whose credential material says otherwise — it raises a typed error instead. The flag records intent; the credential state proves custody.

4. Does local mode re-enable local refresh for an account with a manifest entry? This is the one I'd most like ruled. My position is no: refresh ownership should follow the vault's possession of the family, not the mode, so a manifest entry inerts local refresh in both modes. Otherwise the window between importing credentials and flipping the mode has two refreshers on one family, which is the incident we hit on 09-01 — a local rotation supersedes the vault's copy and the vault's next refresh fails invalid_grant. The cost of my position is that an account in local mode with an entry and an expired local access token is dark: not served, not refreshed. That's bounded and its exit is re-login, and it argues for surfacing the state (enrolled · not serving) rather than tolerating it silently. If it goes the other way, importing outside the transition has to be forbidden outright.

Also confirming the fallback side of local: since the transition drops local refresh material, local means re-login for every migrated account, not just main, and those rows should stay as needs-login rather than being removed — removing them loses the label, ordering, quota history, and the manifest binding.

@iceteaSA

iceteaSA commented Sep 4, 2026

Copy link
Copy Markdown
Contributor Author

Amendment to item 4 above. The openai-auth seat, matching this contract, composed items 2 and 4 and found that as I worded them the documented exit path never terminates.

Walk it: local leaves every migrated account with a manifest entry and no local refresh material, so it's dark. The operator re-logins account X. X now has fresh local material — and still has its manifest entry, which under item 4 inerts local refresh. So X serves until that access token expires and is dark again, and re-login puts it right back. The entry outlives every re-login, so nothing clears it and the loop has no exit.

The error is in my formulation: a manifest entry is evidence the vault possesses a family, not evidence it possesses the family the plugin currently holds. After a local re-login those are different families — which is the same divergence I flagged when I said re-entering claustrum must re-import rather than reuse the entry. The entry is stale the moment the re-login succeeds, but it's still gating refresh as though the vault owned the live credential.

The fix is one missing edge: a successful local re-login clears that account's manifest entry. The operator re-logging in is precisely the assertion that the local family is now authoritative. That keeps item 4's rule intact where it matters — an entry inerts local refresh, so the window between importing a credential and flipping the mode can never have two refreshers on one family — while giving the exit path a terminating step.

The alternative considered was to gate refresh on dropped material rather than on the entry, and make the import drop the local material in the same operation so the two can never disagree. It doesn't work here: the vault CLI is deliberately tenant-blind — it won't parse our state file, which is why we hand it an export — so the copy and the drop are necessarily two writers over two files and the window reopens. That window's failure mode is terminal rather than degraded: a local rotation inside it supersedes the vault's copy, leaving the vault's refresh token dead while its cached access token still serves, so preflight passes, the flip commits, we drop local, and both sides are dead hours later.

Worth naming because it's counterintuitive: making preflight stronger doesn't rescue that. Forcing the vault to prove its refresh works (a get with a large min-TTL) does prove it, but the forced rotation invalidates our local copy, since these are single-use rotating families. A preflight trustworthy enough to rely on is itself a commit point, so prove-and-commit have to be adjacent, and the window has to be refresh-inert rather than merely short.

One rule falls out of this that I'd like confirmed with it: re-login while in claustrum mode should be refused ("switch to local first"). Otherwise it's split custody by construction — the operator hands the plugin a live family while the vault serves a different one for the same account. It also matches the exit flow you described.

Per account, that gives: local refresh is active exactly when there's no entry and there is local material · import writes the entry, both present, refresh inert · claustrum preflights every entry and drops material under the locks · local stops vault serving, leaving entry-without-material, dark · re-login writes material and clears the entry, back to a normal local account · re-entering claustrum needs a fresh import, which is correct because the family diverged.

@iceteaSA

iceteaSA commented Sep 4, 2026

Copy link
Copy Markdown
Contributor Author

Scoping correction to my previous comment, raised by the openai-auth seat.

I wrote that a preflight strong enough to prove the vault's refresh token is live is itself a commit point, because forcing the rotation invalidates our local copy. That's a property of Claude's single-use rotating refresh families, not a general one, and I stated it without the qualifier. On their evidence OpenAI's refresh tokens rotate but sibling lineages coexist — they have a recorded pair from real audit data where a vault-side refresh and a plugin-side token 220 seconds apart had different fingerprints and both kept working. If that holds, a forced vault refresh at preflight costs them nothing and their preflight can be the strong one.

So the rule for this repo is: the import-to-flip window must be refresh-inert, and prove-and-commit must be adjacent, because Anthropic's refresh families are single-use rotating. Another provider matching this contract should establish its own answer rather than inherit ours.

Their own caveat is worth carrying with it: one observed coexisting pair is a hypothesis, not a safety property, and it's the kind of vendor behaviour that can change without announcement — so it wants a live test before anything depends on it, and a preflight that falls back to the adjacent ordering if the check ever fails.

Separately, they found that their plugin attempts a refresh of the tombstone before the transport call, which is the fall-through this contract forbids. I checked ours: recognition happens at auth.loader entry (packages/opencode/src/index.ts:4643), before the account id is minted and before the refresh function is even defined, so there's no refresh path to fall through to; and packages/core/src/auth.ts:73 asserts on tombstone material as the first statement of the token exchange, before any network call. Ours is fine, and that two-layer shape — recognise at loader entry, assert inside the exchange — is what makes it un-bypassable regardless of call path.

@ualtinok

ualtinok commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Thanks. The amended state machine is the right direction. Rulings below.

1. Main import remains operator-driven

Agreed. /claude-account claustrum must not shell out to a CLI with a permission flag. The operator imports main into Claustrum first; the command then verifies the manifest binding and usable resident vault credential and commits the handoff. The command surface remains simple even though the secure migration has an explicit prerequisite.

2. Mode persistence

Agreed with the distinction: the mode records operator intent, while credential state proves whether that intent can be served safely. A restored or hand-edited mode value must not override missing/inconsistent custody evidence; it should produce a typed error.

Before implementation review, please add the complete persisted-state/crash-recovery table. This transition spans the manifest, fallback state, and OpenCode auth.json, so startup behavior for every partial commit state must be explicit: resume, roll back, remain dark pending login, or fail closed. We should not infer that behavior from write order.

3. Local refresh ownership and exit

The amended version is approved:

  • A manifest binding inerts local refresh during the import-to-flip window.
  • /claude-account local stops vault serving. Migrated accounts remain as needs login; account rows are not removed.
  • A successful local re-login establishes a new local family and clears that account's manifest binding under the same account/migration lock.
  • Re-login while still in claustrum mode is refused with “switch to local first.”
  • Re-entering claustrum requires a fresh operator import because the local login created a different family.

Manifest clearing must be tied to a verified login completion through our login path. Merely finding local-looking material after restart or backup restoration must not clear the binding or transfer refresh authority.

The Anthropic qualification is correct: this adjacency and refresh-inert window are required because Anthropic refresh families are single-use rotating. Do not generalize that property to other providers without direct evidence.

4. Tombstone prerequisite: correction from a live OpenCode probe

The statement that no tombstone can fail the deployed re-sealer's non-empty access gate while satisfying OpenCode is not correct. I probed OpenCode 1.18.26 with this exact auth value:

{
  "type": "oauth",
  "access": "",
  "refresh": "claustrum-tombstone:v1:anthropic",
  "expires": 0
}

Observed behavior:

  • auth.loader ran and received the exact value;
  • Anthropic remained in the provider catalog;
  • a model request reached the loader-provided custom fetch.

That shape therefore satisfies OpenCode while failing the re-sealer gate as described (access is empty). The takeover rework should use an exact tombstone tuple of this form and update isCustodyTombstoneOAuth accordingly; recognition should remain narrow and assertNotCustodyTombstone should continue to reject the refresh sentinel before network access.

I still want Claustrum PR #28's reserved-prefix refusal merged and deployed before production main migration. It is defense in depth against other import paths and future watcher changes, but it is not true that OpenCode forces us to use a non-empty access tombstone.

With those rulings, please proceed with the replacement design. The intended custody controls remain only:

/claude-account claustrum
/claude-account local

Bare /claude-account continues to report status, and existing non-custody account-management actions remain. No per-account custody gates or custody <id> on|off surface.

@iceteaSA

iceteaSA commented Sep 4, 2026

Copy link
Copy Markdown
Contributor Author

One narrow thing to rule on before I write the recognition code, because two plugins are about to implement it and we've each derived a different answer.

Your ruling said to use the exact empty-access tuple, update isCustodyTombstoneOAuth accordingly, and keep recognition narrow. It didn't enumerate the conjuncts, and the delta matters.

What I have in the plan:

WRITE     (production emits exactly one shape)
          { type: 'oauth', access: '', refresh: custodyTombstoneKey(provider), expires: 0 }

RECOGNISE type === 'oauth' && refresh === custodyTombstoneKey(provider)
          access and expires are IGNORED — no other conjunct

What the openai-auth reviewer ruled for their port: the same, plus expires === 0, with access accepted as empty-or-sentinel.

I think the extra conjunct is wrong, on the reviewer's own stated worry. The case he wants excluded is a row with empty access and a missing or arbitrary refresh — an ordinary broken account that must not read as custodied — and refresh === sentinel already excludes that completely. What expires === 0 adds is a miss on any row that carries the sentinel in refresh with a non-zero expires: a crash mid-write, a hand-edited file, a future build that orders its writes differently. Such a row has the sentinel in the field that carries the custody claim, so the vault owns it, and not recognising it is the dangerous direction — it's what lets the row proceed toward a refresh.

General form: every additional conjunct in a recognition predicate is another way to miss, and the consequences are asymmetric. A miss proceeds toward a token-endpoint POST; a spurious match merely refuses to serve. Narrowness should come from the exactness of the constant (equality against the provider-scoped value, never a prefix), not from adding fields.

Separately, and I'd like this on the record as deliberate rather than as an oversight a later reviewer tidies away: the loader-entry predicate and the token-exchange assertion are not the same check and shouldn't be merged.

RECOGNISE  loader entry     "is this OURS, so install the vault path?"     exact, provider-scoped
REFUSE     exchange + send  "must this never reach a token endpoint?"      prefix-wide, any provider
INVARIANT  refusal ⊋ recognition, tested as ONE assertion, with a foreign-provider
           tombstone witnessing strictness at every refusal depth

The innermost guard's job is "never put this on the wire", not "classify this correctly", so it should be the broadest refusal — and it belongs on the value being committed (a bearer header and a token body take strings), not on the record it came from. Narrowing it to match the classifier is the same outcome as having no second layer, arrived at by tidying. I nearly did this myself: after establishing that our exchange-level prefix guard was load-bearing, I steered the plan to "collapse both layers to one predicate" and it came back doing exactly that. The openai-auth reviewer independently asked for the same merge on their PR the same afternoon. Two of us reaching for it in one day is why I want the containment test in the repo rather than a comment.

Worth noting the asymmetry that makes this concrete on our side: our two layers currently disagree by accident — loader is refresh === key && access === key, exchange is startsWith(PREFIX) — and that accident is the only reason the empty-access tuple you ruled for doesn't reach Anthropic's token endpoint today. The openai-auth port has the cleaner structure (one shared predicate, the assert delegating to it) and no guard inside the exchange at all, and they observed the sentinel reaching their refresh path live. So I'd rather keep the divergence and make it directed than harmonise it.

Happy to go either way on expires === 0 — I just don't want the two plugins shipping different answers to "what is a tombstone" when an operator can copy these artefacts between machines.

@iceteaSA

iceteaSA commented Sep 4, 2026

Copy link
Copy Markdown
Contributor Author

The persisted-state / crash-recovery matrix you asked for is written — full Cartesian, 36 rows over mode (local|claustrum) × main slot (real|tombstone|absent-or-malformed) × fallback local material (real|tombstoned|mixed) × custody evidence (vault-usable|not), each with a named verdict from LOCAL_SERVE / CLAUSTRUM_SERVE / RESUME_TAKEOVER / ROLL_BACK_TO_LOCAL / REMAIN_DARK_PENDING_LOGIN / FAIL_CLOSED. No row serves silently, and the reconcile runs before any OAuth route or refresh timer exists.

Two things in it are worth your explicit eye, because both are decisions rather than derivations.

1. Write order is mode-FIRST, and I had it backwards. My first ruling said the mode field should be the last durable write, and that main's tombstone must never be written while the mode still reads local. Those contradict — under mode-last the tombstone is by definition written while the mode still reads local. Mode-first resolves it better than either half:

  • it eliminates the state I was actually afraid of, since a tombstone never coexists with a local mode during a normal commit — so observing that pair is evidence of tampering or an aborted rollback, not an expected intermediate;
  • it is what makes RESUME_TAKEOVER possible at all. Under mode-last every crash intermediate is indistinguishable from "someone hand-wrote a tombstone in local mode", so the only available verdict is dark-pending-login and the operator re-runs the whole transition.

The cost is that a hand-edited or restored claustrum mode becomes a reachable claim, which is precisely what your typed-error requirement covers: every C | * | * | vault-not-usable row is FAIL_CLOSED or an explicit rollback, never a serve.

2. One row has an operator-facing consequence I want ruled rather than assumed. mode=claustrum, main=real, fallbacks=real, vault not usable — i.e. the mode was committed but the transition crashed before any material write, and now the vault is cold at boot. The matrix says ROLL_BACK_TO_LOCAL, emit a typed mismatch, and do not serve until the next boot or an explicit command. It self-heals: the next boot reads local | real | real and serves normally.

The tradeoff is that a transient vault outage at boot costs one boot of downtime while a perfectly good local credential sits in the slot. The alternative — roll back and serve immediately — removes that downtime but lets a transient outage silently exit custody mode, which is a policy change made by a network blip. I chose the conservative side because it matches your "a restored or hand-edited mode value must produce a typed error" rule, but it is a UX cost on a path that is reachable without anyone doing anything wrong, so I would rather you ruled it than inherited it.

Everything else follows from your rulings directly. The matrix is a review precondition as you asked, so nothing downstream of it gets implemented until you have looked at it — the plan gates Task 3 (the preflight/transition coordinator) on your approval of these rows, not just on my having written them.

@ualtinok

ualtinok commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Two rulings, plus one review blocker on the matrix.

Tombstone recognition

Use the plan you proposed:

WRITE
  { type: 'oauth', access: '', refresh: custodyTombstoneKey(provider), expires: 0 }

RECOGNISE
  type === 'oauth' && refresh === custodyTombstoneKey(provider)

access and expires are deliberately not recognition conjuncts. The exact provider-scoped refresh value is the custody claim. Once that value is present, a different access/expiry value is a partial write or corrupt state that must still enter the vault/tombstone path rather than approach local refresh. Empty access with a missing or arbitrary refresh remains an ordinary broken local credential and is not recognised.

Also keep the two layers distinct and pin their strict containment in tests:

  • loader recognition: exact provider-scoped refresh key;
  • token-exchange and bearer-send refusal: any string beginning with CUSTODY_TOMBSTONE_PREFIX, regardless of provider.

A foreign-provider tombstone should therefore fail Anthropic classification but still trip every wire-level refusal guard. The production writer remains exact even though the recogniser is intentionally tolerant of the other fields. Please align the other plugin to this contract rather than adding expires === 0 there.

The claustrum | real | real | vault-not-usable crash row

Do not automatically roll this back to local, and do not serve local on the next boot.

Main import and manifest enrollment happened before /claude-account claustrum; therefore the remaining local material may be a duplicate of the same Anthropic refresh family already owned by the vault. A transient inability to prove the vault state does not transfer refresh authority back to local. Persisting local and serving on the next boot would contradict the rule that a manifest binding inerts local refresh and can recreate the dual-refresher invalid_grant failure.

The safe verdict is:

  • retain mode=claustrum;
  • retain the local material temporarily but keep it inert and unserved;
  • return a typed takeover incomplete / vault unavailable failure;
  • resume preflight and takeover when the vault becomes usable;
  • require explicit /claude-account local followed by re-login if the operator wants to abandon custody.

A transient network outage must not silently change custody policy. This also avoids the extra-boot behavior: reconciliation can resume on a bounded custody retry once the vault is available, while OAuth routing and local refresh remain disabled.

If there was never a valid manifest binding/import, that is a different state (mode=claustrum with absent custody evidence), not the same generic vault-not-usable row. It should fail closed pending explicit operator correction.

Mode-first is acceptable only with this fail-closed/resume behavior; it is the durable takeover intent, not permission to infer local ownership from leftover bytes.

Matrix review blocker

I cannot approve the full matrix from its summary because it is not present in the PR and the head is still unchanged at 4a9a1695. Please commit the actual table (or paste/link it here) before Task 3.

The stated vault-usable | not axis is too coarse unless the table further distinguishes at least:

  • manifest absent, malformed, or valid;
  • daemon unavailable/cold versus credential needs_reauth;
  • identity or record-version mismatch;
  • main usable while only a subset of enabled fallbacks is usable;
  • per-fallback partial destructive progress rather than one aggregate mixed state.

Those states have different recovery actions. For multiple fallbacks, the safe model is a per-account state machine plus a global all-accounts commit barrier; an aggregate mixed cell cannot prove which local material corresponds to which manifest/vault lineage.

Please include the startup verdict, serving/refresh permissions, durable writes, retry trigger, and operator recovery action for each equivalence class. Then the matrix can be reviewed as the implementation contract rather than reconstructed from prose.

@iceteaSA

iceteaSA commented Sep 4, 2026

Copy link
Copy Markdown
Contributor Author

Both rulings taken. The crash-row one corrected a real error of mine, and I want to name it rather than just comply: I treated "local material is real" as "local material is safe to serve". Under custody it may be a stale duplicate of the same refresh family the vault has since rotated, so serving it is the dual-refresher invalid_grant path — the incident we already had once, arriving through a boot-reconcile door instead of a background-timer door. Retaining claustrum with the material inert is right, and "a transient inability to prove the vault state does not transfer refresh authority back to local" is the sentence I should have written myself.

Your matrix objection is also correct and it invalidated the structure, not just the resolution. An aggregate fallbacks: real|tombstoned|mixed axis cannot express which local material corresponds to which manifest binding, so mixed was never a state — it was three different states wearing one label. Rewritten as a per-account state machine plus a global commit barrier. The .opencode/ tree is gitignored in this repo, so the table is inline here as you asked.

Axes

Evaluated independently for main and for each enabled OAuth fallback. API-key routes are out of scope and unaffected.

axis values
binding VALID · MALFORMED · ABSENT — the manifest entry for this account
local REAL (usable local refresh material) · INERT (fallback material dropped, or main slot holding the production tombstone) · GONE (absent/unparseable)
vault USABLE · COLD (daemon unavailable, not resident, transient) · REAUTH (record latched needs_reauth) · MISMATCH (identity or record_version disagreement) · N/A (no handle to resolve)

Global: mode ∈ {local, claustrum}.

mode = claustrum

# binding local vault startup verdict serve local refresh durable writes retry trigger operator recovery
C1 VALID INERT USABLE CUSTODY_SERVE vault inert none none
C2 VALID REAL USABLE COMMIT_INCOMPLETE_MATERIAL vault inert finish this account's commit under its lock (drop fallback material / install main tombstone) immediate none
C3 VALID REAL | INERT COLD TAKEOVER_INCOMPLETE_VAULT_UNAVAILABLE no inert none — no rollback, no drop bounded custody retry on vault availability none required; /claude-account local + re-login only to abandon custody
C4 VALID any REAUTH CUSTODY_CREDENTIAL_LATCHED no inert none none — retry cannot fix a latched record re-import into the vault; the account resumes without a mode change
C5 VALID any MISMATCH CUSTODY_IDENTITY_MISMATCH no inert none none manual reconciliation — a different account may sit behind this handle
C6 ABSENT REAL N/A NOT_ENROLLED no no none none import + enroll, or exit to local
C7 ABSENT INERT | GONE N/A ORPHANED no no none none import + enroll, or exit to local and re-login
C8 MALFORMED any any BINDING_UNPARSEABLE no no none — never auto-repair a manifest entry none repair or remove the manifest entry

mode = local

# binding local startup verdict serve local refresh durable writes operator recovery
L1 ABSENT REAL LOCAL_SERVE local yes none none
L2 ABSENT GONE DARK_PENDING_LOGIN no no none /login
L3 VALID INERT | GONE MIGRATED_DARK no no none /login (clears the binding on verified completion)
L4 VALID REAL DARK_PENDING_VERIFIED_LOGIN no no none /login through our own path
L5 MALFORMED any BINDING_UNPARSEABLE no no none repair or remove the entry

L4 is the row your M3 ruling creates. Local material present alongside a live binding means material appeared without a verified login through our path — a restored backup, a hand-edit, or a copied file. The binding still inerts refresh and the account stays dark until a real login clears it. This is why "material exists" cannot be the clearing signal.

The global commit barrier

Per-account classes decide serving; they do not decide committing.

  • Commit is all-or-nothing. Preflight computes every enabled account's class first. Unless every one is C1 or C2, the transition performs zero destructive writes and returns a typed failure naming the first failing account and class.
  • Serving is per-account and independent of the barrier. Main may serve from the vault while a fallback sits in C3; the reverse also holds. This is the "main usable while only a subset of fallbacks is usable" case — it needs no aggregate state because nothing about serving account A depends on account B.
  • Local-refresh inerting follows the binding alone, in both modes, independent of vault reachability. A valid binding means the vault owns that family; a cold daemon is not evidence to the contrary.
  • Resume uses the same barrier. A boot that finds C2 accounts finishes only those, under their own locks; it does not re-run a transition for accounts already in C1.
  • The barrier is what mode=claustrum licenses. Mode-first is durable takeover intent; per-account evidence is what authorises serving or committing. Neither infers the other.

Reachability note

C2 and C3-with-REAL are the mode-first crash intermediates. Under mode-last they would be unreachable, but so would RESUME — every intermediate would be indistinguishable from a hand-written tombstone in local mode, leaving only dark-pending-login and a full operator re-run. That is the tradeoff mode-first buys, and rows C3–C8 are where it is paid for.

I've left Task 3 gated on your approval of these rows rather than on my having written them.

@ualtinok

ualtinok commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

This is materially better: per-account evaluation plus a global readiness barrier is the correct decomposition, and the corrected C3 verdict matches the custody invariant. Task 3 is not approved yet because the table still has concrete gaps.

1. The table is not yet Cartesian

These declared-axis combinations have no row:

claustrum | VALID | GONE | USABLE
claustrum | VALID | GONE | COLD
local     | ABSENT | INERT

If a combination is unreachable, state the invariant that makes it unreachable and pin that invariant in a test. Otherwise give it a verdict.

GONE is especially important for main: an absent/malformed OpenCode auth slot can prevent auth.loader from running on the next provider construction. With a valid binding, usable vault credential, and durable claustrum intent, either reconciliation installs the exact tombstone before provider construction or it fails closed; that behavior cannot remain implicit.

Likewise, distinguish a legitimate dropped fallback credential from an unparseable fallback runtime row. The former is custody evidence (INERT); the latter is corruption (GONE) and must not be normalized into success accidentally.

2. “All-or-nothing” is a readiness barrier, not an atomic commit

The proposed writes span files and can leave C1/C2 mixtures after a crash, so please call this an all-accounts readiness barrier rather than an all-or-nothing commit.

The coordinator contract must say how the barrier remains true under multiple OpenCode processes:

  1. acquire the required account/migration locks in deterministic order, or capture and fence every relevant manifest/account generation;
  2. compute and revalidate every enabled OAuth account as C1/C2 while fenced;
  3. persist mode=claustrum;
  4. perform idempotent per-account material commits;
  5. on any later write failure, retain Claustrum intent, keep all local refresh inert, and resume only the incomplete accounts.

A preflight computed before acquiring the commit fences is stale by construction if another process can alter a binding, login, enable state, or credential lineage before the mode write.

3. Split identity mismatch from record-version fencing

MISMATCH currently combines identity mismatch with “record_version disagreement,” but those do not have the same meaning. A Claustrum record version advances normally when the vault refreshes a credential. The current handle manifest contains label, handle, and credentialId, not a durable record version, and ordinary version advancement must not make an account unavailable.

Record version is a request-provenance fence for reporting a 401 against the exact credential that served it. Identity/credential-binding mismatch is a custody-state failure. Please keep those separate and define exactly which two values are compared before adding a startup MISMATCH verdict.

4. Add the operation transition table

Startup reconciliation alone does not close the state machine. Please specify and test at least:

  • enabling an OAuth account while in Claustrum mode: require a valid usable binding or refuse;
  • disabling/removing an account: define whether the manifest binding remains and whether vault material is retained;
  • local login while in Claustrum mode: refuse before credentials are written;
  • verified login while in local mode: commit the new local family, then clear the binding under the same fence;
  • adding a new OAuth account while already in Claustrum mode: define the operator workflow that creates both the vault credential/binding and the account row without a transient local refresher;
  • manifest change or account enable/disable racing with the global barrier.

The “new account while already in Claustrum mode” path is currently a liveness hole if every plugin login is refused but the plugin still requires a pre-existing account row for routing metadata. If Claustrum CLI login plus manifest discovery is the intended path, say how the account row, label/order, and enabled state are created and fenced.

Once the missing rows, version semantics, commit fencing, and operation transitions are explicit, the matrix can gate Task 3. The core C1–C8/L1–L5 policy is otherwise pointed in the right direction.

@iceteaSA

iceteaSA commented Sep 4, 2026

Copy link
Copy Markdown
Contributor Author

Revision 2, against all four gaps. Two facts were probed first because the answers rest on them (citations are to main @ v1.22.0 and OpenCode 339536bc22):

  • F1 — what credential.get returns. payload, expires_at_ms, record_version, optional project_id, account_id, email, org_name (claustrum/crates/credentials-module/src/read_surface.rs:272-303; our decoder reads the first five, packages/core/src/claustrum.ts:556-613). There is no credential_id on the get wire, and the Rust source states account_id is neither the credential id nor the handle (:283-290). The manifest binding is exactly {label, handle, credentialId} with no durable version (claustrum.ts:158-169, 210-229). record_version is expected_version + 1 on every commit_refresh (store.rs:1931-1951).
  • F2 — loader gating. OpenCode runs auth.loader only when a stored entry exists: const stored = …; if (!stored) continue (provider/provider.ts:1604-1619). An absent slot never reaches the loader; a slot that fails the Info decode (e.g. missing refresh) is silently filtered by Auth.all() (auth/index.ts:56-67) and likewise never reaches it. Plugin factories are invoked during the first Provider-state construction via plugin.list() (provider.ts:1436) but before the provider reaches auth.all()/the loader (:1591-1622) — so an awaited write from our factory is visible to the first loader pass.

3 — identity mismatch vs record-version: split, and the comparands named

You are right that these were conflated, and the conflation was a defect: under my table a normal vault refresh (F1: record_version advances on every commit) would have made an account unavailable. Two fences, never combined:

fence what it compares when effect
RECORD_VERSION the record_version captured from the resolution that served this request's token (index.ts:1757-1777), passed unchanged to report_auth_failure for this response (:6178-6180) per request, on a 401 provenance only. Never compared at startup, never affects availability. A version that advanced between sends is the vault doing its job.
IDENTITY vault account_id (the provider account UUID attached via ck auth set-identity) vs the account row's persisted anthropicAccountUuid startup reconcile and on each custody tick both present and unequal → MISMATCH: refuse serve, no writes, surface in status; operator: set-identity or re-bind. Either absent → UNLABELLED: serve, status shows it; not a failure — absence is not difference.

credentialId is not a startup comparand: nothing on the get wire can be compared to it (F1). It is the join key for the quota feed and operator tooling only. Request-time bootstrap of the served token yields the same UUID; a disagreement there refuses that request with the same MISMATCH reason, but is a per-request check, not a startup verdict.

1 — the missing rows, and INERT vs GONE

F2 changes what GONE means for main: it is not "main unavailable", it is "our request path does not exist" — no loader, no fetch hook, no custody. So reconciliation runs in the plugin factory, not in the loader, and it awaits the slot write.

INERT — the row parses and refresh material is deliberately absent. Main: the slot holds the exact write-set tombstone. Fallback: refresh absent/empty, row otherwise valid. Custody evidence.
GONE — main: slot absent or fails the Info decode. Fallback: the runtime row fails normalizeAccount. Corruption. Today normalizeAccount null-drops such a row silently and the loader hides an absent slot; reconciliation therefore reads raw rows and slots so GONE is surfaced, and a GONE fallback's state secrets are retained (never pruned — the #174 rule), never normalised into success.

mode binding local vault verdict serve refresh durable write retry operator
claustrum VALID GONE (main) USABLE RESTORE_TOMBSTONE → then C1 vault, after restore inert auth.json ← exact write-set tombstone, awaited in the factory before the loader pass write fails → FAIL_CLOSED typed main unavailable: slot unrestorable; next boot none on success
claustrum VALID GONE (main) COLD RESTORE_TOMBSTONE → then C-cold no (typed provider-unavailable) inert same write — it is non-secret and independent of the vault; the slot must exist for the loader to run at all custody tick none
claustrum VALID GONE (fallback = unparseable row) any CORRUPT_ROW no inert none next reconcile repair the row, or remove + re-discover
local ABSENT INERT AWAITING_LOGINexpected: this is exactly the post-/claude-account local state before re-login no nothing to refresh none on verified login re-login

Every remaining mode × binding × local × vault combination now has either a row (plan file) or a named invariant with a pinned test; the three you listed were the only unrowed reachable ones.

2 — an all-accounts readiness barrier, and its multi-process contract

Renamed. The coordinator, with every step inside the fences:

  1. Acquire in this fixed total order — config write lock → cross-tenant manifest lock → per-account refresh locks (main, then fallbacks by sorted id). Hold all through step 4. Deadlock-free by total order; TTL/renewal on the manifest lock covers the awaited host write.
  2. Inside the locks: capture the per-account custody generation, and re-read manifest, account rows, and the auth slot. Any preflight computed before this point is advisory and its result is discarded — stale by construction, as you say.
  3. Classify every enabled OAuth account as C1/C2 while fenced. Any other class → release, no writes, typed refusal naming the account.
  4. Persist mode=claustrum (config lock held) — the barrier's durable marker and the only global write.
  5. Idempotent per-account commits, fallbacks then main: fallback → drop local refresh material (no-op if already absent); main → client.auth.set(tombstone), awaited (no-op if the slot already satisfies the recognise-set).
  6. Any failure after step 3: retain the mode, keep all local refresh inert (the binding alone inerts it — mode-independent), release, surface; the next reconcile resumes only incomplete accounts (binding VALID ∧ local REAL ∧ mode=claustrumRESUME_TAKEOVER).

Against other processes: enable/disable and login take the config lock, so they serialise with steps 0–4; other tenants' manifest writes take the cross-tenant lock, so they serialise too; a generation bump observed at step 4 for one account aborts that account's commit only — the others proceed and resume covers it.

4 — operation transition table

operation mode precondition effect fence
enable account claustrum binding VALID vault USABLE (COLD is a typed refusal, not a wait) enabled=true config lock
enable account local enabled=true config lock
disable any enabled=false; binding unchanged; vault material untouched (the plugin never writes the vault) config lock
remove account claustrum row removed; binding removed under the manifest lock; vault material retainedck auth owns vault removal config + manifest locks
local login claustrum refused before the browser opens or anything is written (Exit Claustrum mode first: /claude-account local) none needed
verified login local login completed through our path (in-process record) ∧ observed material commit the new local family, then clear that account's binding — both under config + manifest locks in one fence; bumps the generation config + manifest locks
add new OAuth account claustrum vault-side tooling created the credential and the binding under --serve anthropic-auth reconcile discovers a binding with no row → creates {id: label, enabled: false, no local material} (INERT from birth — never a local refresher), appended to the fallback order; operator then enable (which requires USABLE) config lock, manifest re-read inside it
manifest change (other tenant) racing the barrier serialised by the cross-tenant lock; if it lands between step 1 and 4 → generation bump → that account's commit aborts → resume manifest lock + generation
enable/disable racing the barrier serialised by the config lock config lock

The new-account row is created disabled and without refresh material so the liveness hole closes without a transient local refresher: there is never a moment where a row exists, is enabled, and lacks a usable vault binding. One item is flagged rather than asserted: the discovery path assumes Claustrum's tooling writes our provider block in the shared manifest (it has the co-tenant writer and the lock); confirming that with them before Task 3 executes.

Plan file carries the same text.

@iceteaSA

iceteaSA commented Sep 4, 2026

Copy link
Copy Markdown
Contributor Author

Addendum to Rev 2 — the one flagged assumption was wrong, checked against Claustrum master decab7f:

Nothing vault-side writes our provider block today. mint-handle prints a handle and writes no manifest entry; migrate-opencode / opencode-account add accept --serve-by anthropic-auth but write an OpenCode-shaped entry (label == main) under our tenant name — a foreign block to us, misfiled to them. migrate-plugin --serve anthropic-auth (follow-up tag, not master) does write our block, but its input is a plugin-exported file — it brings existing local fallbacks in; it cannot create a brand-new account. So "add new OAuth account in claustrum mode" had no discovery target.

Resolution, agreed with Claustrum:

  • Direct path — new verb ck auth bind --serve anthropic-auth --label <label> --id <credential_id> (scoped on their side). Mints → verifies the handle resolves → writes exactly {label, handle, credential_id} into our block under the cross-tenant lock → verifies → revokes the handle on any failure after mint. Refuses an existing label without --replace; never touches our account rows. Chosen over a plugin-side verb because a handle must never sit at rest between two commands when one can mint-and-persist with revoke-on-failure — "mint succeeded, bind failed" would leave a live bearer capability nothing tracks. The vault already writes our block via migrate-plugin and --allow-main, so the settled model is vault writes bindings, plugin discovers them and only removes its own; bind is consistent with that.
  • Interim path (every step exists on the tag): /claude-account local/loginck auth migrate-plugin --from <export> --replace/claude-account claustrum. Heavy, but it is the already-ruled re-entry path, so the PR never claims "no path".

Discovery row semantics unchanged: {id: label, enabled: false, no local material}, operator enables (requires USABLE).

One provenance note on the IDENTITY comparand, from read_surface.rs:849-858: account_id is account_id_for_adapter(adapter, token).or_else(stored identity), and the live parse derives only for openai. So for Anthropic the vault's account_id is the operator-asserted set-identity label, while our request-time bootstrap of the served token is provider-asserted. The startup check (vault label vs persisted UUID) catches a mislabelled or swapped record; the request-time check is the one that catches a label that is itself wrong. Both yield MISMATCH; the request-time one is authoritative. For OpenAI the precedence inverts (token claim wins; their write sink refuses a contradicting label), so a port of this table should not copy the Anthropic reading.

@iceteaSA iceteaSA changed the title feat(custody): /claude-account custody on|off for vault-served fallbacks feat(custody): global claustrum|local mode with main-account vault takeover Sep 4, 2026
@iceteaSA

iceteaSA commented Sep 4, 2026

Copy link
Copy Markdown
Contributor Author

Rev 2, second addendum: one TOCTOU the readiness barrier did not close, plus where the table lands.

Host auth.json writes sit outside every fence the barrier holds. OpenCode's Auth.set (the /login callback) takes neither our config lock nor the manifest lock. A login that starts in local and completes after the barrier's step 1 read puts a fresh real family in the main slot that the barrier never classified. Two shapes, one fix:

  • Step 3 persists, with mode=claustrum, a pre-commit fingerprint of the main slot material step 1 read. Step 4 re-reads the slot immediately before client.auth.set and aborts main's commit if the fingerprint differs: the account stays REAL-under-claustrum with a typed verdict instead of being clobbered.
  • RESUME_TAKEOVER becomes fingerprint-gated. It re-tombstones only material matching the persisted pre-commit fingerprint (crash-left, already classified). A different family under claustrum is a new verdict, NEW_LOCAL_FAMILY_UNDER_CLAUSTRUM: refuse serve, local refresh inert (the binding), no writes, surfaced. The operator resolves it with local (the login stands) or a fresh migrate-plugin --replace and re-entry.

Without the fingerprint, crash-left pre-commit material and a raced login are byte-indistinguishable, and resume would silently tombstone a fresh login. The in-process "login completed through our path" record does not cover this: it does not survive a restart, and the crash case is the one where the process is gone.

Where the table lands: as an in-tree docs/custody-state-machine.md in this PR, not only as these comments. The reasoning is what a future reader needs and a diff will not carry it.

One deliberate divergence from the sibling plugin's table, stated so it is not read as an oversight: for a GONE main slot with a valid binding and an identity MISMATCH, we still install the tombstone. It grants nothing, so it never transfers authority; what it does is make auth.loader run so a typed MISMATCH can exist. Otherwise the loader never runs and the operator sees OpenCode's generic not-logged-in in place of the custody failure. No install when there is no binding.

@iceteaSA

iceteaSA commented Sep 4, 2026

Copy link
Copy Markdown
Contributor Author

Last comment before the table moves in-tree. I claimed Cartesian coverage in Rev 2 and it is still short by three cells; naming them so you do not have to. The binding axis needs a third value, INVALID (entry present, handle or credentialId fails validation).

mode binding local verdict serve refresh operator
claustrum INVALID REAL CORRUPT_BINDING no inert: a corrupt binding must not silently re-enable a local refresher on a vault-owned family ck auth bind --replace, or local
claustrum ABSENT INERT ORPHAN_TOMBSTONE no nothing to refresh bind, or local + re-login
local VALID INERT AWAITING_LOGIN, binding lingering (exit ran, clear did not land) no inert (binding) re-login; the verified login clears it

Two rules that fell out of the same pass:

  • Discovery writes no identity. A discovered row has anthropicAccountUuid absent (UNLABELLED, serves) and binds from the first served token's bootstrap, never from the vault's operator-asserted label and never from a placeholder. MISMATCH fires only when a persisted UUID disagrees. A placeholder at discovery would mismatch the real claim forever.
  • The pre-commit fingerprint is refresh-only. Anthropic rotates both tokens on every refresh and local refresh is inert under claustrum, so any rotation between crash and resume means something outside the barrier touched the slot. That yields NEW_LOCAL_FAMILY_UNDER_CLAUSTRUM (refuse), which is the safe direction; a fingerprint that tolerated rotation is the one that would clobber.

Rev 1, Rev 2, and the addenda consolidate into a single docs/custody-state-machine.md as a docs-only commit on this branch. That file is what should gate Task 3, not this comment stream.

@ualtinok

ualtinok commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

Thanks for the investigation and the revisions. Please commit the consolidated docs/custody-state-machine.md with the current findings and unresolved questions, then pause implementation. We will take over the remaining design and implementation locally, retaining attribution for your work; we will not merge the current per-account toggle implementation as-is.

The remaining host-write race needs a source-backed synchronization solution before implementation: reading a matching fingerprint immediately before client.auth.set is still check-then-write. An OpenCode login can persist a new credential between those operations and have it overwritten by the tombstone. Restoring an absent slot has the same race. The fingerprint is useful for crash reconciliation, but it does not make the host write atomic.

Please also preserve the unresolved local-mode recovery distinction in the document: allowing a raced login to stand must be reconciled with the rule that bindings clear only after verified login. No further design rounds are needed from you before the documentation handoff; we will investigate those mechanisms directly and resolve the design before coding.

iceteaSA added a commit to iceteaSA/anthropic-auth that referenced this pull request Sep 5, 2026
Consolidates the PR cortexkit#196 design rulings into one in-tree artefact: tombstone
write/recognise/refuse sets and the containment invariant, the three fences
(record version, identity, pre-commit fingerprint), the per-account state
tables for both modes, the all-accounts readiness barrier with its
multi-process contract, the operation transition table, the verified facts
each row rests on, and the deliberate divergences from the openai-auth port.

Design only; no behaviour changes. The branch still carries the superseded
per-account toggle, which the rework replaces.
@iceteaSA

iceteaSA commented Sep 5, 2026

Copy link
Copy Markdown
Contributor Author

The consolidated table is in-tree: docs/custody-state-machine.md (2f41ce8, docs-only commit). It supersedes Rev 1, Rev 2, and the three addenda above; review that file rather than the comment stream.

What changed from the comments, in one place:

  • binding axis is VALID · INVALID · ABSENT; local is REAL · INERT · GONE with GONE split (SLOT_ABSENT/SLOT_UNPARSEABLE for main, ROW_UNPARSEABLE for fallbacks; an absent fallback row under a binding is the discovery operation, not a coordinate). MISMATCH is a fence result, not a vault value.
  • Three fences, never combined (§4): record version (per-request 401 provenance only), identity (both-present-unequal, with the Anthropic provenance note), and the pre-commit fingerprint that makes RESUME_TAKEOVER safe against a login racing the barrier through Auth.set.
  • Ten claustrum rows (C1–C10) plus named invariants for the collapsed cells; six local rows (L1–L6). RESTORE_TOMBSTONE installs on USABLE, COLD, and REAUTH so a typed verdict can exist; no install without a binding.
  • Barrier as a readiness barrier with the multi-process contract and mode-first reasoning (§7); operation transitions including the interim and direct paths for a new account under custody (§8).
  • §9 lists every external fact a row rests on with its source; §10 states the three deliberate divergences from the openai-auth port so neither is read as an oversight.

Still gated on your approval of §5–§8 before Task 3 is implemented.

iceteaSA added a commit to iceteaSA/anthropic-auth that referenced this pull request Sep 5, 2026
Consolidates the PR cortexkit#196 design rulings into one in-tree artefact: tombstone
write/recognise/refuse sets and the containment invariant, the three fences
(record version, identity, pre-commit fingerprint), the per-account state
tables for both modes, the all-accounts readiness barrier with its
multi-process contract, the operation transition table, the verified facts
each row rests on, and the deliberate divergences from the openai-auth port.

Design only; no behaviour changes. The branch still carries the superseded
per-account toggle, which the rework replaces.
@iceteaSA
iceteaSA force-pushed the feat/custody-toggle branch from 4538b76 to 2f41ce8 Compare September 5, 2026 06:00
@iceteaSA

iceteaSA commented Sep 5, 2026

Copy link
Copy Markdown
Contributor Author

Committed and paused. docs/custody-state-machine.md @ 8ac3a8d now carries the handoff status and a §12 "Unresolved questions" section with both items you named, stated as open rather than resolved:

  • §12.1 The host-write race. The fingerprint is downgraded to crash reconciliation only (§4, §7 step 4): a re-read before client.auth.set is check-then-write against an unfenced Auth.set, and RESTORE_TOMBSTONE has the same race for an absent slot. What is needed is a synchronisation mechanism grounded in OpenCode source; none was found in 339536bc22, and the search was not exhaustive.
  • §12.2 Raced login vs. verified-login clearing. The earlier "exit to local; the login stands" resolution is withdrawn from C2′ and the contradiction is written out: a raced login has no in-process record, so under local it is L5, not L1, and its binding does not clear. Both rules are individually correct and jointly unreconciled.

No further design rounds from me. Implementation on this branch is stopped; the per-account toggle commit stays only as history. Thank you for the attribution.

@ualtinok

ualtinok commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

Change of plan: please proceed with the implementation using the consolidated document as the baseline. We will review the result and implement remaining corrections ourselves on top. This supersedes our request to pause implementation; it is permission to start Task 3, not approval to merge or activate takeover against live credentials.

Please carry these findings into the implementation and document any unresolved items explicitly:

  1. The host-write race remains open. A fingerprint re-read followed by client.auth.set is not atomic: OpenCode can write a newly logged-in credential between those operations and the tombstone can overwrite it. Restoration of an absent slot has the same race. Fingerprints protect reconciliation against already-observed changes, not this concurrent write window. Our config/manifest/refresh locks do not fence host Auth.set. Do not describe this as solved or weaken the invariant to make a test pass; keep the unsafe transition blocked until a real synchronization solution is available. The serving path and other independent work can proceed.
  2. Returning to local mode must consistently require verified login before clearing a surviving binding. A raced or restored local credential must not become refreshable merely because the operator changes mode. Reconcile any wording that says the raced login simply stands with that rule.
  3. Keep production handoff correctness separate from our local operational observation procedure. The document’s references to contacting a Claustrum seat and receiving sealer/latch-watch log edges are local verification steps, not a shippable runtime dependency for the command. Production readiness and completion need explicit machine-checkable contracts.

Please preserve regression coverage for the exact host-write interleaving (including absent-slot restoration), crash/resume, concurrent mode changes, and verified-login binding clearance. Mark the host-write test/transition as unresolved rather than presenting a passing fingerprint-only test as proof of safety. No live migration or release is authorized by this implementation go-ahead.

Thanks for consolidating the investigation. We will take ownership of the final integration and remaining fixes after your implementation pass.

@iceteaSA

iceteaSA commented Sep 5, 2026

Copy link
Copy Markdown
Contributor Author

Source-backed answer to the §12 host-write race, from the Claustrum seat (the vault side, which will be the party writing the tombstone via migrate-plugin --allow-main). All citations are OpenCode at 339536bc22 (~/projects/opencode); the deployed build may differ by a few lines, not in shape.

What the host gives you: nothing

  • No lock, no CAS. packages/opencode/src/auth/index.ts:73-81Auth.set is all() → spread → writeJson(file, …, 0o600). Auth.remove (:83-89) is the same shape. Nothing is held between the read and the write.
  • Not even an atomic write. packages/opencode/src/util/filesystem.ts:61-84writeJson is writeFile(p, content, {mode}) in place: truncate-then-write, no temp+rename. A concurrent reader can observe a partial file (my rotation probes have seen mid-write partials in production; the sealer treats an unparseable entry as transient for exactly this reason). So "re-read before set" can also read garbage, not just stale.
  • No write event. Nothing in auth/index.ts watches the file; the provider loader is memoized init-once (provider.ts, the if (!stored) continue gate), so a running seat does not re-read the tombstone until restart. Inotify from the plugin is possible but it is a signal after the fact, not a fence.

So a check-then-write against Auth.set cannot be made race-free at the host. The question becomes: can the flip be made convergent — loses detectable, retries bounded, the residual loud.

The vector nobody has named yet: the env snapshot clobbers deterministically

packages/opencode/src/control-plane/workspace.ts:532-533 — workspace children are spawned with OPENCODE_AUTH_CONTENT: JSON.stringify(yield* auth.all()). And auth/index.ts:59-61Auth.all() prefers that env snapshot over disk when it is set. Therefore in any child, Auth.set = stale env snapshot + one key → written over the whole file. That is not a probabilistic race: a child that refreshes any provider after the flip writes the pre-flip real material for anthropic back to disk, every time, and it will keep doing so on every refresh for its lifetime. It also re-plants real material for every static key the OpenCode custody plugin has tombstoned (this is the mechanism behind the 09-02 SplitCustody fleet incident on icebox).

Checkable precondition, from outside: grep -l '^OPENCODE_AUTH_CONTENT=' /proc/*/environ (after tr '\0' '\n') must be empty before the flip and any child started after it inherits a post-flip snapshot. On icebox right now: 0 such processes, 8 OpenCode seats.

The probabilistic race is live on a multi-seat box

Every seat's built-in OAuth providers refresh through Auth.set (icebox has 8 providers in auth.json and 8 seats). Each refresh is a whole-file RMW of a few ms; a flip write landing inside one is silently overwritten with the pre-flip entry. Rare per event, but the flip is one write against a file that is rewritten many times an hour.

What I would build (vault-side, since the vault holds the material and writes the file)

  1. Import first, tombstone second — already the migrate-plugin order. The vault holds the family before the file changes, so a lost tombstone write costs a retry, never the token.
  2. Write → settle → re-read → classify, bounded retries:
    • entry is the tombstone → done;
    • entry is real material byte-equal to what was just imported → a stale writer clobbered it (the RMW race or an env-snapshot child) → rewrite the tombstone (bounded, e.g. 5 × with a settle window longer than one writeFile);
    • entry is real material different from what was imported → a newer family landed locally after the import → refuse loud, do not overwrite: the vault now holds the older family, and the honest state is "split, operator decides", not a silently-won write.
  3. Residual, named: the "different family" case cannot be prevented at the host; it is detected here and, if missed, surfaces at the vault's first refresh as invalid_grant → latch → desktop alert (claustrum-latch-watch lifts main's exclusion the moment the entry is a tombstone, so that path is already covered on the alerting side).
  4. Do not flip while env-snapshot children exist (the /proc check above), and restart seats after the flip — the memoized loader means running seats keep serving the pre-flip classification regardless.

None of this is a lock; it is idempotence + a byte-compare against the material the writer just consumed + a loud refusal on the one case that is genuinely ambiguous. I can implement (2)–(3) in migrate-plugin --allow-main on the claustrum side if you want it there; it is the natural home since that verb already has the imported bytes in hand.

Upstream, the real fix is Auth.set doing temp+rename with an O_EXCL lock file or a compare-and-swap on the file's prior content hash — opencode#46128 is the tracking issue; nothing has landed as of 339536bc22.

@iceteaSA

iceteaSA commented Sep 5, 2026

Copy link
Copy Markdown
Contributor Author

Acknowledged; proceeding under the three constraints, recorded as §13 of the document (e67afbb) so they bind the implementation rather than the comment thread:

  1. The main-slot write (tombstone install and absent-slot restoration) is blocked with a typed error until a source-backed synchronisation against Auth.set exists. Tests demonstrate the exact host-write interleaving in both directions and assert the block; the fingerprint is documented as crash reconciliation only and is not presented as safety.
  2. Returning to local never makes material under a surviving binding refreshable; only a login verified through our path clears the binding. The "login stands" wording is gone.
  3. The Claustrum-seat observation steps are reframed as an operator runbook. The command's completion contract is machine-checkable in-plugin: slot re-reads as the recognise-set, credential.get through the binding succeeds, status reports CUSTODY_SERVE, zero local refresh attempts observed.

Fallback half of the barrier, serving path, mode persistence, manifest layer, and the verified-login exit proceed. No live migration, activation, or release. Implementation commits land on this branch as replacements for the toggle, not amendments to it.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

All reported issues were addressed across 8 files (changes from recent commits).

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread packages/opencode/src/custody-live.ts

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

All reported issues were addressed across 2 files (changes from recent commits).

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread README.md
Comment thread docs/custody-state-machine.md Outdated

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

1 issue found across 5 files (changes from recent commits).

Confidence score: 5/5

  • In packages/e2e-tests/tests/mock-claustrum.test.ts, the raw-socket helpers assume the hello handshake arrives in one data event and have no timeout or correlation, so split TCP segments can drop fragments and make later responses unreliable; buffer complete messages and add bounded, correlated reads.
Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/e2e-tests/tests/mock-claustrum.test.ts">

<violation number="1" location="packages/e2e-tests/tests/mock-claustrum.test.ts:137">
P3: The new raw-socket helpers read a single data event with no timeout and no correlation. If the daemon ever splits the hello handshake across TCP segments, the leftover fragment is silently dropped and the later `response` reader would then consume that stale fragment instead of the error frame; if the daemon never replies, `await response` hangs until the test runner's timeout. Buffer reads and add a timeout so a daemon regression fails fast with a clear message rather than a confusing mismatch or a slow timeout.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread packages/core/src/accounts.ts
return socket
}

function nextSocketData(socket: Socket): Promise<Buffer> {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3: The new raw-socket helpers read a single data event with no timeout and no correlation. If the daemon ever splits the hello handshake across TCP segments, the leftover fragment is silently dropped and the later response reader would then consume that stale fragment instead of the error frame; if the daemon never replies, await response hangs until the test runner's timeout. Buffer reads and add a timeout so a daemon regression fails fast with a clear message rather than a confusing mismatch or a slow timeout.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/e2e-tests/tests/mock-claustrum.test.ts, line 137:

<comment>The new raw-socket helpers read a single data event with no timeout and no correlation. If the daemon ever splits the hello handshake across TCP segments, the leftover fragment is silently dropped and the later `response` reader would then consume that stale fragment instead of the error frame; if the daemon never replies, `await response` hangs until the test runner's timeout. Buffer reads and add a timeout so a daemon regression fails fast with a clear message rather than a confusing mismatch or a slow timeout.</comment>

<file context>
@@ -54,4 +64,86 @@ describe('fake Claustrum daemon', () => {
+  return socket
+}
+
+function nextSocketData(socket: Socket): Promise<Buffer> {
+  return new Promise((resolve, reject) => {
+    socket.once('data', (chunk) => resolve(Buffer.from(chunk)))
</file context>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Declined for this PR: the raw-socket helpers are negative-path probes; a split hello would fail the assertion, not pass it. Tracked for the post-merge hygiene pass.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

All reported issues were addressed across 8 files (changes from recent commits).

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread packages/core/src/accounts.ts Outdated
Comment thread docs/custody-state-machine.md Outdated
… state machine spec

Squashed from:
  feat(custody): add /claude-account custody on|off for vault-served fallbacks
  docs(custody): add the global-mode custody state machine
  docs(custody): record the handoff and the unresolved design questions
  docs(custody): record the implementation go-ahead and its three binding constraints
  docs(custody): source the host-write race and collapse unobservable main-slot states
…lock

Squashed from:
  feat(core): add a secure reader and resolver for the Claustrum handle manifest
  feat(opencode): write our provider block into the handle manifest and resolve handles from it at every site
  fix(custody): harden the cross-tenant manifest lock to the shared contract
  docs(custody): withdraw plugin-side install into an absent main slot
  docs(custody): under the block, main's tombstone is a barrier precondition, not a plugin write
  docs(custody): reword manifest onboarding for global custody mode
  feat(opencode): migrate legacy custody handles into the manifest at startup
  fix(custody): close review findings on the landed manifest layer
  fix(test): restore the account fixture's gate field removed by the wording pass
  style(tui): reformat the custody status line touched by the wording pass
  fix(claustrum): keep the manifest temp file inside the lock directory so an evicted writer cannot overwrite its successor
  fix(claustrum): refuse a group-writable non-sticky manifest parent
  refactor(claustrum): one discriminator, one credential-id builder, portable sleep
  test(custody): pin sticky-parent, unknown-owner-key, and ugly-foreign-block behaviour
  docs(claustrum): explain why, not who, at the lock's pinned checks
…he per-account handler

Squashed from:
  feat(core): persist a global claustrum mode with one locked writer
  feat(core): parse /claude-account claustrum|local and retire the per-account custody vocabulary
  refactor(core): derive vault ownership from mode, enabled OAuth, and a resolved binding
  docs(custody): a resolved binding of either source satisfies the ownership predicate
  fix(core): vault ownership accepts any resolved binding, not only manifest-sourced
  feat(opencode): retire the per-account custody handler in favour of the global mode
  test(core): retire tests of the removed per-account gate helper
  fix(opencode): restore the startup migration's imports and finish the custody-mode projection types
  test(opencode): migrate custody fixtures to the global mode and retire toggle tests
  test(opencode): retire the per-account custody handler tests
  test(opencode): drive legacy-handle migration and lock coverage from startup
  test(opencode): seed the global claustrum mode in the handle-blindness fixtures
  test(opencode): seed the global claustrum mode where fixtures relied on the per-account gate
  test(opencode): let fixtures choose local mode explicitly and flip the mode mid-session
  test(opencode): await both startup migrations before asserting the shared manifest
  test(custody): pin legacy gates outside ownership
  test(custody): synchronize concurrent manifest migration
  fix(core): merge partial claustrum config fields
…under custody, TUI mode control

Squashed from:
  feat(core): split the custody tombstone into write, recognise, and refuse sets
  feat(opencode): mode-aware tombstone handling at loader entry and a value guard at the send boundary
  feat(opencode): refuse OAuth login while claustrum mode is committed
  fix(core,opencode): assert the bearer value is not a tombstone inside the header builders
  test(opencode): pin every tombstone entry path
  test(opencode): state the tombstone containment invariant as one assertion
  refactor(opencode): drop the transition override that duplicates the executor default
  fix(core): guard the profile fetch's bearer value against a custody tombstone
  docs(custody): reconcile the main-slot refusal with the C2/C2' classification
  test(opencode): seed legacy flag fixture
  docs: align global Claustrum custody guidance
  fix(core): normalize custody status and handles path
  fix(opencode): guard custody startup and login edges
  fix(custody): align duplicate-label detection with resolution
  feat(tui): show the global custody mode with one control and treat an absent mode as unknown
  docs(custody): the barrier's eligibility step names the main-slot precondition
  fix(core): guard persistent custody mode
  fix(tui): retain account projection on apply failure
  test: harden custody hygiene checks
  docs: align global custody guidance
… policy and the identity fence

Squashed from:
  feat(core): mode-aware vault ownership prevents local refresh inside the manager
  feat(opencode): project per-account custody state into status
  feat(opencode): vault-only routing under claustrum with the cold-route policy
  test(opencode): retire sidecar-fallback tests superseded by the cold-route policy
  test(opencode): re-express cold-vault coverage as absence from the candidate set
  test(opencode): expect the projected custody state labels
  feat(custody): serve warmed main bindings
  fix(custody): fence vault-owned refreshes
  fix(core): corrupt-binding is a malformed entry, not an unreadable manifest
  feat(custody): derive identity mismatch from vault account id
  test(custody): cover vault account identity mismatch
  test(custody): pin distinct main refusal guidance
…ord version

Squashed from:
  feat(opencode): fence main vault 401 reports to the send-time record version
  feat(opencode): bind main identity to the vault credential and extend the identity fence to main
  test(opencode): fence tombstones before refresh
  chore(custody): use the status label helper for cold rows and index sticky fallbacks once
  test(opencode): fence stale main vault 401 reports
  test(opencode): show unknown main custody identity
  test(opencode): classify main custody refusals
…er brand the slot key as a provider uuid

Squashed from:
  feat(opencode): bind main quota identity to vault credential
  fix(opencode): never brand the local slot key as a provider uuid
  fix(opencode): preserve served fallback uuid in quota feed
…re-login through the plugin

Squashed from:
  feat(custody): remove local manifest bindings safely
  feat(custody): gate manifest clear on local login proof
  feat(custody): reject unobservable local logins
  fix(custody): preserve loader fast path without completion
  test(custody): mutation-pin manifest removal and two-factor clear
  feat(custody): clear bindings after fallback logins
  feat(custody): persist local divergence fence
  fix(custody): satisfy local login lint
  fix(custody): fence every manifest clear path
  fix(custody): narrow authoritative OAuth account
  fix(custody): use served vault version for TUI fence
  test(custody): pin fence persistence ordering
  test(custody): pin auth content guard ordering
  fix(custody): type review regression tests
  test(custody): consume main completion after refusal
  test(custody): guard fresh local install takeover
  test(custody): TUI fallback re-login fences at the served vault record version
  refactor(opencode): share the Claustrum 401 relay hook between main and fallback
  fix(opencode): cli builds custody credential ids through the canonical helper
  refactor(opencode): cli fence baseline goes through the never-served path of the helper
…rrier, generic vault-main route, structural gate

Squashed from:
  feat(custody): add preflight startup reconcile
  fix(custody): type preflight contracts
  feat(custody): add takeover rollback coordinator
  feat(custody): add live coordinator adapters
  feat(custody): reconcile startup before refresh
  fix(custody): takeover main lock is the production main refresh lock
  fix(custody): resolve the manifest path from loaded storage
  fix(custody): reject mismatched vault credential identities
  fix(custody): tolerate null storage during divergence preflight
  test(custody): update main lock contention expectations
  fix(custody): isolate live adapter test state
  test(custody): equal-UUID vault fallback waits for readiness
  fix(custody): reconcile every OAuth loader state
  fix(custody): require operator-migrated main
  fix(custody): route vault main through resolver
  fix(custody): bound the startup vault warm so a cold vault never blocks readiness
  test(custody): ruled-row fixtures for fallback Claustrum credential resolution (batch A, part 1)
  test(custody): ruled-row fixtures for fallback Claustrum credential resolution (batch A, part 2)
  test(custody): ruled-row fixtures for fallback Claustrum credential resolution (batch A, part 3)
  test(custody): share the ruled-row fixture helper
  test(custody): assert manifest stays off request path
  test(custody): migrate vault 401 fixtures
  test(custody): cover vault expiry fixture paths
  test(opencode): migrate ruled custody rows batch c1
  test(opencode): migrate ruled custody rows batch c2
  test(custody): restore the shared fallback fixture quota shape
  test(custody): ruled-row fixture boots from the storage path the plugin reads
  test(opencode): rule TUI divergence fixture
  test(opencode): rule credential blindness rows
  test(opencode): rule handle report dedupe
  test(opencode): preserve ruled handle sentinels
  test(opencode): rule handle blindness rows
  test(opencode): rule sticky vault rows
  test(opencode): rule killswitch vault refresh
  test(opencode): use ruled fixture wrapper
  test(opencode): type ruled custody fixture
  fix(custody): the construction-phase custody gate is structural; vault residency is route darkness
  test(custody): vault reauth row boots in the ruled state
  test(custody): type the ruled-row fixture
…ck, and an all-refusals preflight

Squashed from:
  feat(custody): commit takeover writes mode last
  feat(custody): route account mode commands through takeover
  test(custody): update mode transition fixtures
  test(custody): prove takeover write fences
  fix(custody): skip absent vault credential ids
  feat(custody): report every takeover refusal
  fix(custody): a failed post-commit read-back reverts the mode before restoring sidecars
…de-write capability, manifest hygiene, identity attribution

Squashed from:
  fix(custody): clear refused vault main access
  fix(custody): privatize mode write capability
  fix(custody): preserve non-expiring vault credentials
  fix(tui): render custody mismatch verdicts
  fix(custody): clear duplicate manifest bindings
  fix(custody): replace corrupt manifest bindings
  fix(cli): validate account labels before saving
  fix(custody): reap stale manifest lock quarantines
  fix(custody): a transient manifest refusal keeps the completed local login pending
  fix(custody): align cache-result and dialog payload types after the expiry and mismatch changes
  fix(custody): bind fallback identity to served account
  fix(custody): preserve bootstrap fallback identity
  fix(quota): retain newer main refresh slot
  fix(custody): prefer current sidebar quota credential
… residency, cold-tick recovery

Squashed from:
  fix(opencode): drop credential cache test seam
  test(custody): keep fallback residency route-local
  test(custody): recover cold vault quota refreshes on tick
  test(custody): keep bound real fallback dark
  test(custody): the takeover command never calls the host auth writer
  test(custody): preserve resident tick evidence
…gainst the takeover

Squashed from:
  test(custody): fence takeover behind refresh lock
  fix(custody): fence stale writes over tombstones
  test(custody): ban process dependencies
  fix(custody): admit fresh login lineages
…ion out of the plugin entry

Squashed from:
  docs(custody): name the identity fence
  refactor(custody): remove unused mode commit export
  refactor(custody): close main refusal states
  refactor(custody): move takeover command orchestration
  refactor(custody): extract custody dimensions
Conflicts resolved: PR cortexkit#198 owns the feed schema, entry projection, validator, lease reaping
and the anthropic_account_uuid semantics; this branch keeps the vault-identity plumbing
(ProviderAccountUuid brand, quotaKey/providerAccountUuid separation, vault account_id as identity).
Includes the post-merge type-contract completion.
…t with no local material is vault-owned when bound

Squashed from:
  test(custody): record fresh install routing gap
  test(custody): cover mixed-version account dialogs
  fix(custody): admit bound fallback roster rows
  fix(custody): classify bound empty fallbacks as vault owned
  fix(custody): route resident vault fallbacks before quota
  test(custody): type first-run status capture
  test(custody): cover cold main fallback routing
  test(custody): pin vault 401 provenance
  test(custody): document cold main refusal coverage
  test(custody): exclude sidecar 401 reports
  test(custody): cover local exit refusal
  test(custody): cover fresh fallback re-login
  test(custody): cover re-entry after fallback login
…ient manifest refusals stay pending

Squashed from:
  fix(custody): revert mode when committed verifier throws
  fix(custody): defer transient manifest refusal retries
  test(custody): remove ineffective mode-write option
…e Claustrum daemon

Squashed from:
  test(e2e): runnable fake Claustrum daemon fixture
  test(e2e): custody mode serves main and fallback from the vault
  test(e2e): a cold vault main is a typed refusal, not a transport
  test(e2e): report fallback vault auth failures
  test(custody): pin fallback 401 reporting
…, identity projection, tombstone metadata freshness

Squashed from:
  fix(custody): apply policy to empty vault fallbacks
  fix(custody): main is not projected vault-served without identity evidence
  fix(custody): serialize local exit with takeover
  fix(custody): retain stored tombstone metadata
  fix(custody): merge tombstone metadata by freshness
  test(custody): type the identity projection fixture
  test(custody): complete identity manifest fixture
Squashed from:
  docs: document global claustrum custody
  docs: define custody matrix axes
@iceteaSA
iceteaSA force-pushed the feat/custody-toggle branch 4 times, most recently from 0d7823c to 6c01a04 Compare September 7, 2026 09:48
…tore creation before locking; one missing-credential predicate

Squashed from:
  fix(e2e): capture reported provider status
  fix(e2e): reject unassigned claustrum routes
  fix(core): skip quota refresh during custody verification
  fix(custody): create local store before locking
  refactor(custody): centralize missing credential check
  refactor(identity): centralize provider UUID casts
  docs: tighten Claustrum custody guidance
  docs(custody): distinguish the cold main case
  docs(identity): explain persisted UUID naming

  docs(custody): describe the latched main case without settling §12.4; name the persisted uuid field's owners
  fix(core): a fallback quota poll skips while another process verifies that account's custody
  test(opencode): scope the custody manifest env to the test that sets it

  fix(custody): do not name a vault import verb that does not ship yet

  test(core): isolate the account storage path in every core test

  test(opencode): never detect the host's live Claustrum connection file from tests
@iceteaSA
iceteaSA force-pushed the feat/custody-toggle branch from 6c01a04 to 5d42c6c Compare September 7, 2026 10:11
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