diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c51de3b..6a684398 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ This repo is a CortexKit-maintained Anthropic auth monorepo for OpenCode and Pi. The OpenCode package is a fork of the original `@ex-machina/opencode-anthropic-auth` plugin, so older entries below the initial CortexKit release are inherited from upstream package history. +## Unreleased + +### Minor Changes + +- Document the global `/claude-account claustrum|local` custody mode, including its fail-closed OpenCode behavior and Pi refusal. +- Document global Claustrum manifest onboarding, startup legacy-handle migration, and vault re-login recovery. + ## 1.22.0 ### Minor Changes diff --git a/README.md b/README.md index 87f8b1de..2f103135 100644 --- a/README.md +++ b/README.md @@ -116,15 +116,21 @@ Override the path with `PI_ANTHROPIC_AUTH_FILE`. The package also respects `PI_A ## Primary account authentication -Each integration keeps the host agent's normal Anthropic login as the primary account. - -For OpenCode, use OpenCode's Anthropic auth flow: +OpenCode keeps its native `anthropic` entry as the main account. In `local` mode, sign in through OpenCode: ```text /connect anthropic ``` -The primary account remains OpenCode's built-in `anthropic` auth entry. The OpenCode plugin intercepts final Anthropic requests and supplies the OAuth headers and request transforms needed for Claude Pro/Max access. +Before the main account can enter Claustrum custody, the operator onboards it into the vault with Claustrum's tooling. Until the dedicated import tooling lands, use the interim path in the custody state machine's §8, “Adding a new account today.” The plugin never runs `ck`, never imports or migrates credentials, and never writes the host auth slot. + +Verify the migration gate against the installed `ck-auth` binary, not an announcement or a claimed deployment revision. In an isolated scratch data directory, a production-shaped tombstone import must refuse with `refusing Claustrum tombstone material` and leave the audit chain unchanged; in the same run, a real-material import with `--replace` must succeed. The refusal alone is not enough because a broken import path also refuses. The exact tombstone write is: + +```json +{ "type": "oauth", "access": "", "refresh": "claustrum-tombstone:v1:anthropic", "expires": 0 } +``` + +`access` is empty so the sealer's shape gate rejects the material by construction. OpenCode's upstream authentication options are still supported: @@ -186,9 +192,6 @@ Example: "quotaHeaderFeed": { "enabled": false }, - "claustrum": { - "accounts": {} - }, "killswitch": { "enabled": false, "main": { "five_hour": 5, "seven_day": 10, "scoped": 0 }, @@ -233,8 +236,6 @@ The `routing` block controls `/claude-routing`, `claudeCache` controls `/claude- `quotaHeaderFeed.enabled` is an OpenCode-only, restart-required opt-in. It publishes only allowlisted quota-window values, an opaque account reference, an observation timestamp, and the configured OAuth-account count; it never publishes tokens, raw headers, request bodies, model IDs, or refresh errors. Per-process lease files use owner-only permissions under `$TMPDIR/opencode-anthropic-auth/quota-header-feed`, expire after three minutes, and can be redirected with `OPENCODE_ANTHROPIC_AUTH_QUOTA_FEED_DIR`. -`claustrum.accounts..enabled` is an OpenCode-only, per-account opt-in for fallback credential custody. It is inert unless that account's capability handle has been provisioned in `anthropic-auth-state.json`; handles are bearer credentials and must not be copied into the public config file. - Runtime data is stored separately in `anthropic-auth-state.json`: fallback OAuth tokens, API-route keys, token refresh backoff, quota snapshots, and quota API backoff. `sticky-balanced` session assignments use a separate `anthropic-auth-routing-state.json`; session IDs are SHA-256 hashed in that file. Background refresh and quota checks write only runtime state, so editing `anthropic-auth.json` does not get overwritten by another running plugin instance. ## OpenCode lane-start setting @@ -287,24 +288,15 @@ Fallback OAuth tokens refresh in the background so idle accounts do not expire b If Anthropic reports `invalid_grant`, that account must be logged in again. `/claude-account reset-backoff` manually clears the main account's refresh backoff and its matching quota backoff. -### Optional Claustrum custody (OpenCode) - -OpenCode can obtain an opted-in fallback OAuth account's access credential from a local [Claustrum](https://github.com/cortexkit/claustrum) daemon instead of refreshing that account independently. After provisioning the account's runtime handle, enable custody by account ID: +### Claustrum manifest service (OpenCode) -```json -{ - "claustrum": { - "accounts": { - "personal-alt": { "enabled": true } - } - } -} -``` +Claustrum custody is global. `/claude-account claustrum` enters custody, `/claude-account local` returns to local authentication, and bare `/claude-account` shows status. There are no per-account custody switches. Claustrum uses a handle manifest written by Claustrum tooling. -The request path reads only a resident in-memory credential. Startup warming and periodic custody ticks perform vault I/O and keep idle credentials refreshed; a cold or unavailable vault falls back to the sidecar credential path. Vault-served 401 reports carry the exact record version and response provenance, including relay-stream 401s, so a sidecar-served failure cannot invalidate a healthy vault credential. `/claude-account` and the OpenCode account modal show the gate, current vault service, and vault reauthentication state without exposing capability handles. +Entering custody preflights every enabled OAuth account. A refusal changes nothing, and the command reports every refusal in account order. The main account must already have been migrated by the operator. The plugin does not create or import vault records during this check. -Custody currently applies only to fallback OAuth accounts. Main-account vault service is not implemented. If Claustrum has replaced the main host credential with its provider-bound tombstone, the plugin rejects refresh locally without contacting Anthropic or persisting a permanent `invalid_grant` state. +In custody, every enabled OAuth route is served from the vault, including the main account. A cold main vault record returns a typed startup refusal and holds every OAuth route until the next viable boot; after a warm boot, it returns a typed provider-unavailable error. The plugin does not fall back to sidecar credentials or send a tombstone as a bearer token. A cold fallback is excluded only for that request, so other warm routes can still serve. +Leaving custody puts the main account back into interactive OpenCode sign-in. A fallback binding clears only after a login completed through the plugin's own login flow observes new credential material. To enter custody again for that fallback, the operator must import the new material into the vault with `--replace`; until then, `/claude-account claustrum` refuses with `binding_missing`. API-key routes are unaffected. ## Quota-aware routing When `quota.enabled` is true, the plugin checks Anthropic's OAuth usage endpoint and applies the configured remaining-quota thresholds to both main and fallback accounts. @@ -332,6 +324,14 @@ In OpenCode, this includes the main Anthropic account and sidecar fallback accou Reset times are rendered as relative durations, such as `resets in 10m` or `resets in 1h 15m`. +### Quota header feed + +The optional quota header feed writes one lease file per process under `${TMPDIR:-/tmp}/opencode-anthropic-auth/quota-header-feed/`. Set `OPENCODE_ANTHROPIC_AUTH_QUOTA_FEED_DIR` to override the directory. A file contains only accounts whose response headers THAT process harvested. Consumers MUST union entries from every file inside `lease_horizon_ms`, then deduplicate by account. "Newest file wins" drops accounts seen by other processes. + +For each account, the newest entry wins for quota values. Resolve `anthropic_account_uuid` from any entry in that account's group that carries it. During a rollout, an older pre-restart publisher can write the newest entry without that field beside newer code that has it. + +Each entry always includes `anthropic_account_uuid`; an absent key identifies an older producer. Its value is provider-derived or `null`, never a local substitute. A consumer that sees `null` must count a gap, not fall back to `account_ref`; that field is store-local and never a join key. + ## Safety fallback (OpenCode) Eligible Fable 5/5.1 and Opus 5 OAuth requests try Anthropic's server-side safety fallback first. The plugin sends `fallbacks: "default"` with Anthropic's server-side fallback beta, preserves fallback conversation boundaries in OpenCode history, and reports model handoffs and restoration in the TUI sidebar or OpenCode Desktop. Follow-up requests may remain on Anthropic's selected fallback model for approximately one hour. diff --git a/docs/custody-state-machine.md b/docs/custody-state-machine.md new file mode 100644 index 00000000..1ecea813 --- /dev/null +++ b/docs/custody-state-machine.md @@ -0,0 +1,505 @@ +# Custody state machine: global `claustrum` | `local` mode with main-account takeover + +Design record for PR #196 (rework). This file is the artefact that gates implementation of the +mode transition; the PR comment stream (Rev 1, Rev 2, three addenda) is superseded by it. The +reasoning is kept in-tree because a diff will not carry it and every row here was paid for by a +ruling or an incident. + +Status: **implementation baseline** (maintainer, 2026-09-05 06:04Z, PR #196). This document is the +baseline for the implementation pass; the maintainer reviews the result and owns final integration +and remaining corrections. The go-ahead is permission to build, **not** approval to merge or to +activate takeover against live credentials; no live migration or release is authorised by it. Three +constraints bind the implementation (§13). The branch still carries the superseded per-account +toggle, which will not be merged as-is. + +## 1. Scope and vocabulary + +Two global modes, persisted in `anthropic-auth.json`: + +- `local` (default): the plugin refreshes and serves OAuth credentials from OpenCode's `auth.json` + (main) and its own sidecar (fallbacks). No vault calls are made in this mode. +- `claustrum`: every OAuth route, **main included**, is served from the Claustrum vault through a + handle-manifest binding. The vault is the sole refresher of every bound family. + +Commands are exactly `/claude-account claustrum`, `/claude-account local`, and bare +`/claude-account` for status. There is no per-account custody toggle, no `claustrum.enabled` +flag, and no `on|off` synonym. Account membership is decided by the handle manifest, not by +configuration. API-key routes are out of scope and unaffected in both modes. + +**Mode records intent; credential state proves servability.** `mode=claustrum` licenses the +takeover barrier and the custody serving path. It never by itself makes an account servable, and +a per-account verdict never by itself changes the mode. + +## 2. The tombstone: one write set, one classifier, one wider refusal + +Main's slot in `auth.json` cannot be empty: OpenCode runs a plugin's `auth.loader` only when a +stored entry exists (`provider/provider.ts:1604-1619`, `if (!stored) continue`), and an entry +that fails the `Info` decode is silently filtered by `Auth.all()` (`auth/index.ts:56-67`). An +absent or malformed slot therefore means **our request path does not exist**: no loader, no fetch +hook, no custody. Under `claustrum` the slot holds a non-secret tombstone whose only job is to +make the loader run. + +### 2.1 WRITE set (production writes exactly this, nothing else) + +```json +{ "type": "oauth", "access": "", "refresh": "claustrum-tombstone:v1:anthropic", "expires": 0 } +``` + +`access` is **empty**, and that is load-bearing, not cosmetic. OpenCode's `Info` schema is +`access: Schema.String` with no non-empty constraint, so the slot decodes and the loader runs +(verified live on 1.18.26 by the maintainer, reproduced on `openai`). Claustrum's deployed sealer +runs a shape gate that **aborts on empty access but accepts sentinel access**; an empty-access +tombstone therefore fails vault import by construction, independently of Claustrum's +reserved-prefix refusal (#28). Anything that later "tidies" this to carry a descriptive `access` +value silently re-arms the destructive import path. The constant carries this comment. + +### 2.2 RECOGNISE (classifier: "is this MY provider's tombstone?") + +``` +auth.type === 'oauth' && auth.refresh === custodyTombstoneKey(provider) +``` + +`access` and `expires` are **not** conjuncts. Once the exact provider-scoped sentinel is present in +`refresh`, a different `access`/`expires` is a partial write or corrupt state that must still enter +the custody path, never approach local refresh. Every extra conjunct is another way to *miss*, and a +miss falls through to a wrong-state boot: the loader does not refuse, `mainAccountId` is minted, +quota-identity resolution runs its compat substitution, and the background refresh loop starts +before the exchange-level guard finally throws. A spurious match merely refuses to serve. + +Both artefact shapes are pinned by tests: the empty-access shape production writes, and the +sentinel-access shape Claustrum's vendored golden encodes (legacy). A golden-only test is green +about a shape production never writes. + +### 2.3 REFUSE (barrier: "is this tombstone material at all?") + +Prefix-wide, at the last point before the irreversible act, on the **value committed** rather than +the record it came from: + +- token exchange: `assertNotCustodyTombstone` is the first statement of `refreshClaudeOAuthToken`, + keyed on `refresh.startsWith(CUSTODY_TOMBSTONE_PREFIX)`, before any `URLSearchParams` is built; +- send boundary: the bearer value is checked for the prefix before header construction. + +These deliberately do **not** share a predicate with §2.2. Recognition is exact so a foreign +provider's tombstone is not adopted as ours; refusal is wide so a foreign provider's tombstone can +never reach Anthropic's token endpoint. Two reviewers on two plugins independently reached to merge +them in one afternoon; the merge would have narrowed the barrier to the classifier, which is the +same failure as having no barrier. + +**Containment invariant, pinned by one test:** `refusal ⊋ recognition`. Every shape recognised at +the loader is refused at the exchange and the send boundary; the witness for strictness is a +**foreign-provider** tombstone (`claustrum-tombstone:v1:openai`), which recognition answers *no* +and refusal answers *yes*. A same-provider witness cannot distinguish the two predicates and so +cannot protect the split. + +## 3. Axes + +Evaluated independently for main and for **each enabled OAuth fallback**. + +| axis | values | notes | +|---|---|---| +| `mode` | `local` · `claustrum` | global, durable; the only global write in the barrier | +| `binding` | `VALID` · `INVALID` · `ABSENT` | the account's entry `{label, handle, credentialId}` in our provider block of the shared handle manifest. `INVALID` = entry present, handle or credentialId fails validation. `VALID` = a RESOLVED binding from the resolver, whichever source it came from: a manifest entry, or (transitionally, for the migration window and the crash-left / unreadable-manifest case) a plugin-written sidecar handle. Source is provenance, not authorization; the resolver owns the binding decision and startup migration converges sidecar handles into the manifest. What membership-by-manifest retires is the per-account *config flag* as an authority, not sidecar-held bindings. | +| `local` | `REAL` · `INERT` · `GONE` | main: `REAL` = usable material, `INERT` = recognise-set tombstone, `GONE` = slot absent **as observed through the SDK**. An unparseable main slot is unobservable: `Auth.all()` runs `Record.filterMap(decode)` (`auth/index.ts:65-66`), so a slot failing the `Info` schema reads as `undefined` and any later host write rewrites the file without it; for main, `GONE ≡ SLOT_ABSENT`. Fallback: `REAL` = usable refresh material, `INERT` = refresh material absent, row otherwise valid, `GONE` = `ROW_UNPARSEABLE` (our own store, so the distinction survives there; a row that is *absent* while a binding exists is the discovery operation, §7, not a coordinate) | +| `vault` | `USABLE` · `COLD` · `REAUTH` · `N/A` | resolved through the binding's handle. `COLD` = daemon unreachable or credential not resident (transient). `REAUTH` = record latched `needs_reauth`. `N/A` ⇔ `binding ∈ {ABSENT, INVALID}` | + +The startup matrix in §5 uses a compact four-letter form: `mode|main|fallbacks|evidence`. + +| letter | meaning | +|---|---| +| `C` | global `claustrum` mode | +| `L` | global `local` mode | +| `R` | real local OAuth material is present | +| `T` | local material is tombstoned or absent while a binding resolves | +| `X` | the main host slot is absent or cannot be observed as a valid OAuth slot | +| `M` | a fallback binding is missing | +| `V` | main vault evidence is verified at startup | +| `N` | main vault evidence is not verified at startup | + +Two facts about `GONE` for main, both from OpenCode source (`339536bc22`), change what it means: + +- an absent slot never reaches `auth.loader`, so any reconciliation that must observe main's slot + before the loader runs lives in the **plugin factory**, which is invoked during the first + Provider-state construction via `plugin.list()` (`provider.ts:1436`) but **before** the provider + reaches `auth.all()` and the loader pass (`:1591-1622`). An awaited write from the factory would be + visible to the first loader; the plugin currently issues no such write (§5, C9). +- today `normalizeAccount` null-drops an unparseable fallback row and `Auth.all()` hides an absent + or malformed slot. Reconciliation reads **raw** rows and slots so `GONE` is surfaced, and a `GONE` + fallback's state secrets are **retained**, never pruned, never normalised into success. + +## 4. Fences (three, never combined) + +| fence | compares | when | effect | +|---|---|---|---| +| **RECORD_VERSION** | the `record_version` captured from the resolution that served *this* request's token, passed unchanged to `report_auth_failure` for *this* response | per request, on a 401 | provenance only. **Never** a startup coordinate, never affects availability. `record_version` is `expected_version + 1` on every vault `commit_refresh` (`store.rs:1931-1951`); an earlier draft that compared it at startup would have made an account unavailable every time the vault did its job | +| **IDENTITY** | vault `account_id` from `credential.get` **vs** the row's persisted `anthropicAccountUuid` | startup reconcile and each custody tick, only when `vault=USABLE` | **both present and unequal → `MISMATCH`** (refuse serve, no writes, surfaced). **Either absent → `UNLABELLED`**: serve; absence is not difference. The request-time bootstrap of the served token performs the same comparison per request and is the authoritative one (§4.1) | +| **PRE-COMMIT FINGERPRINT** | `sha256(len(access) ‖ access ‖ len(refresh) ‖ refresh)` of each account's local material as read inside the barrier's fences | persisted with the mode write; consulted by `RESUME_TAKEOVER` | **crash reconciliation only.** Distinguishes crash-left pre-commit material (fingerprint matches → resume) from material that changed after the barrier read (differs → `NEW_LOCAL_FAMILY_UNDER_CLAUSTRUM`, §5), because content alone cannot tell the two apart and the in-process login record does not survive the restart that the crash case is. **It does not make the host write safe** (§12.1): OpenCode's `Auth.set` sits outside every lock we hold, and a re-read immediately before `client.auth.set` is still check-then-write | + +`credentialId` is **not** a startup comparand: `credential.get` returns `payload`, `expires_at_ms`, +`record_version`, `project_id?`, `account_id?`, `email?`, `org_name?` and **no credential id** +(`claustrum/crates/credentials-module/src/read_surface.rs:272-303`; the Rust source states +`account_id` is neither the credential id nor the handle). `credentialId` is the join key for the +quota feed and operator tooling only. + +### 4.1 Identity provenance (Anthropic-specific; a port must not copy it) + +On the vault side `account_id = account_id_for_adapter(adapter, token).or_else(stored identity)` +(`read_surface.rs:849-858`), and the live parse derives only for `openai`. For Anthropic the vault's +`account_id` is therefore the **operator-asserted** `ck auth set-identity` label, while our +request-time bootstrap of the served token is **provider-asserted**. The startup IDENTITY check +catches a swapped or mislabelled record; only the request-time check catches a label that is itself +wrong. Both yield `MISMATCH`; the request-time one is authoritative. For OpenAI the precedence +inverts (token claim wins; the vault's write sink refuses a contradicting label). + +Fingerprint covers both tokens, length-prefixed. Between the barrier's read and its commit, local +refresh is inert (binding), OpenCode has no Anthropic refresh loop, Anthropic never rotates access +without refresh, and a host login writes a whole new family; so refresh-only and both-tokens are +equivalent in the true-positive direction. They differ on a torn `Auth.set` (new access, old +refresh): both-tokens refuses, refresh-only tombstones an access token that is dead without its +family. Same safety; both-tokens removes an assumption about the atomicity of a file we do not own. + +## 5. `mode = claustrum` + +Per account. `serve` is from the vault where it says `vault`; local material is **never** served in +this mode. Local refresh is inert wherever a binding exists (`VALID` or `INVALID`), independent of +vault reachability: a valid binding means the vault owns that family, and a cold daemon is not +evidence to the contrary. A corrupt binding must not silently re-enable a local refresher on a +vault-owned family. + +| # | binding | local | vault | verdict | serve | local refresh | durable writes | retry | operator | +|---|---|---|---|---|---|---|---|---|---| +| C1 | VALID | INERT | USABLE | `CUSTODY_SERVE` | vault | inert | none | — | none | +| C2 | VALID | REAL, fingerprint **matches** | USABLE | fallback: `RESUME_TAKEOVER` · main: `TAKEOVER_INCOMPLETE_MAIN_REAL` (§13.1) | fallback: vault, after its commit · main: **no** | inert | fallback → drop refresh material under its lock · main → **none** | fallback: immediate · main: none | main: onboard into the vault with Claustrum's tooling | +| C2′ | VALID | REAL, fingerprint **differs or absent** | any | `NEW_LOCAL_FAMILY_UNDER_CLAUSTRUM` (for main under §13.1 this is the *classification* of the `TAKEOVER_INCOMPLETE_MAIN_REAL` refusal: material the barrier did not read, versus C2's crash-left material it did) | **no** | inert | **none** | none | **unresolved** (§12.2): vault import with Claustrum's tooling, then re-enter, is consistent with every rule; "exit to `local` and the login stands" is not | +| C3 | VALID | INERT | COLD | `CUSTODY_UNAVAILABLE` | **no** (typed provider-unavailable) | inert | none | bounded custody retry on vault availability | none | +| C3′ | VALID | REAL | COLD | `TAKEOVER_INCOMPLETE_VAULT_UNAVAILABLE` | **no** | inert | **none**: no rollback, no drop. The destructive commit waits for `USABLE` (→ C2) because dropping material without proof the vault holds the family is destruction without evidence | on vault availability | none required; `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; resumes without a mode change | +| C5 | VALID | any | USABLE ∧ IDENTITY mismatch | `CUSTODY_IDENTITY_MISMATCH` | **no** | inert | none | none | `set-identity` or re-bind; a different account may sit behind this handle | +| C6 | ABSENT | REAL | N/A | `NOT_ENROLLED` | **no** | **no** (this mode has no local refreshers) | none | none | `ck auth bind` after import, or `local` | +| C7 | ABSENT | INERT | N/A | `ORPHAN_TOMBSTONE` (main) / `ORPHAN_INERT` (fallback) | **no** | nothing to refresh | none | none | `bind`, or `local` + re-login | +| C8 | INVALID | any | N/A | `CORRUPT_BINDING` | **no** | **inert** | **none**: never auto-repair a manifest entry | none | `ck auth bind --replace`, or `local` | +| C9 | VALID | GONE (main) | USABLE · COLD · REAUTH | `TAKEOVER_INCOMPLETE_SLOT_ABSENT` | **no** | inert | **none** (plugin-side install into an absent slot is **withdrawn**, see below) | next boot | vault import restores the slot; or the host, once a fenced write exists | +| C10 | VALID | GONE (fallback = `ROW_UNPARSEABLE`) | any | `CORRUPT_ROW` | **no** | inert | **none**; state secrets retained | next reconcile | repair the row, or remove + re-discover | + +At the loader, `evidence` means the main-slot vault evidence only. Fallback residency is route-local: a cold bound fallback is excluded for that request while other routes, including a resident main, continue to serve. Structural fallback dimensions (`R`/`M`) still produce `RESUME_TAKEOVER` or a binding-missing refusal independently of loader evidence. + +Invariants pinning the combinations not rowed: + +- `vault = N/A ⇔ binding ∈ {ABSENT, INVALID}`; a `VALID` binding always resolves to one of + `USABLE | COLD | REAUTH`. Test: every `VALID` fixture produces a connector call; no `ABSENT`/`INVALID` + fixture does. +- `binding = ABSENT ∧ local = GONE` for main is not a custody state: with no binding there is nothing + to restore, so the slot stays as found and OpenCode's own not-logged-in applies (no install without + a binding: that would fabricate custody). +- The recognise-set (§2.2) makes "partial tombstone write" a tombstone for every verdict; there is + no separate local-axis value for it. +- **C9 no longer installs.** An earlier revision restored the tombstone into an absent slot so the + loader would run and a typed verdict could exist. That write is withdrawn: with no lock, no CAS, a + non-atomic host write, and the torn-read amplifier (§12.1), an install into an "absent" slot is + the highest-blast-radius write this plugin could issue (it can wipe every provider's credentials), + and its payoff was a typed verdict in place of the host's generic not-logged-in. The absent slot is + named in status and restored by vault import or by the host once a fenced write lands + (#46128). Two rules bind **any** host-slot write the plugin ever issues: (1) read + `client.auth.all()` first and, if it is EMPTY, abort the write, warn once, retry next tick, since an + empty map on a machine with any configured provider is a torn read until proven otherwise; (2) + treat a slot as absent only after two reads at least 250 ms apart, both `undefined`, both with a + NON-EMPTY `all()`. The install-on-`MISMATCH` divergence from the openai-auth port (§10) is moot + while no install exists. + +## 6. `mode = local` + +No vault call is made in this mode (test: zero connector invocations under `mode=local`). The +`vault` axis is not consulted. + +| # | binding | local | verdict | serve | local refresh | durable writes | operator | +|---|---|---|---|---|---|---|---| +| L1 | ABSENT | REAL | `LOCAL_SERVE` | local | yes | none | none | +| L2 | ABSENT | GONE | `DARK_PENDING_LOGIN` | no | no | none | `/login` | +| L3 | ABSENT | INERT | `AWAITING_LOGIN` | no | nothing to refresh | none | `/login`. **Expected**: this is the post-`/claude-account local` state before re-login | +| L4 | VALID | INERT · GONE | `AWAITING_LOGIN` with a lingering binding (exit ran; the clear did not land or was never reached) | no | inert (binding) | none | `/login`; the verified login clears the binding (§7) | +| L5 | VALID | REAL | `DARK_PENDING_VERIFIED_LOGIN` | **no** | inert (binding) | none | `/login` through our own path | +| L6 | INVALID | any | `CORRUPT_BINDING` | no | **no** | none | repair or remove the entry | + +**L5 is the row the verified-login ruling creates.** Real material alongside a live binding means +material appeared without a login through our path: a restored backup, a hand-edit, a copied file. +"Material exists" is satisfiable by a restore and so cannot be the clearing signal; the binding keeps +refresh inert and the account stays dark until a real login clears it. + +## 7. The takeover barrier (`/claude-account claustrum`) + +An **all-accounts readiness barrier**, not an atomic commit: the writes span `auth.json`, the sidecar, +and the manifest, and cannot be made kill-atomic. The barrier makes every crash-visible intermediate +a state with a named verdict (§5) and a resume path. + +0. 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; the manifest lock's TTL/renewal covers the awaited host write. +1. **Inside** the locks: capture each account's custody generation; re-read the manifest, the raw + account rows, and the raw auth slot; compute each account's PRE-COMMIT FINGERPRINT. Any preflight + computed before this point is advisory and discarded (stale by construction under concurrency). +2. Classify every enabled OAuth account while fenced. Fallbacks: C1 or C2-eligible (VALID binding, + USABLE vault, IDENTITY not mismatched). Main: the same three conditions **plus** the slot already + holds the recognise-set (§13.1) — a REAL main is `TAKEOVER_INCOMPLETE_MAIN_REAL` here, not + eligible. Any other class → release, **zero writes**, typed refusal naming the first failing + account and its class. +3. Persist `mode=claustrum` **and** the per-account fingerprints in one config write (config lock + held). This is the barrier's durable marker and the only global write. Mode-first: a tombstone + never coexists with `mode=local` during a normal commit, so observing that pair is evidence of + tampering rather than an expected intermediate, and it is what makes `RESUME_TAKEOVER` possible + at all (under mode-last every intermediate is indistinguishable from a hand-written tombstone). +4. Idempotent per-account commits, fallbacks only. Fallback → drop local refresh material (no-op + if absent); fallback rows live under our own locks, so this half is fenced. Main → **no write** + (§13.1): the slot must already satisfy the recognise-set at step 2 or the barrier refused before + step 3. (A plugin-side `client.auth.set(WRITE set)` would not be fenced against the host, + §12.1; a fingerprint re-read immediately before it narrows the window without closing it.) +5. 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 (C2), under their own locks; it never re-runs a transition for accounts already in C1. + +Against other processes: enable/disable and **our** login path 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 aborts **that** account's commit only, the others +proceed, and resume covers it. The host's `Auth.set` serialises with nothing we hold (§12.1). + +Serving is per-account and independent of the barrier: main may serve from the vault while a +fallback sits in C3, and the reverse. Nothing about serving account A depends on account B, so no +aggregate state exists. + +## 8. Operation transitions + +| operation | mode | precondition | effect | fence | +|---|---|---|---|---| +| `/claude-account claustrum` | local | barrier §7 | mode + fingerprints, then per-account commits | §7 | +| `/claude-account local` | claustrum | — | `mode=local` only. **No material writes, no manifest writes.** Bindings stay; every bound account becomes L4/L5 and stays dark until a verified login clears it. A transient inability to prove vault state never transfers refresh authority back to local; abandoning custody is this explicit command plus re-login | config lock | +| local login (`Claude Pro/Max` authorize) | 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** OAuth path (in-process record) **∧** real material observed via the live `getAuth` re-read | commit the new family, **then** clear that account's binding, both under config + manifest locks in one fence; bump the generation → L1 | config + manifest locks | +| enable account | claustrum | binding VALID ∧ vault USABLE ∧ IDENTITY not mismatched (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 | config lock | +| remove account | claustrum | — | row removed; **its** binding removed under the manifest lock; vault material **retained** (`ck auth` owns vault removal; the plugin never writes the vault) | config + manifest locks | +| add new OAuth account | claustrum | vault-side tooling created the credential **and** the binding in our provider block | reconcile **discovers** a VALID binding with no row → creates `{id: label, enabled: false, no refresh material, no identity}`, appended to the fallback order. INERT from birth: there is never a moment where a row exists, is enabled, and lacks a usable vault binding. **Discovery writes no identity**; the row binds `anthropicAccountUuid` from the first served token's bootstrap, never from the vault's operator-asserted label and never from a placeholder (a placeholder would `MISMATCH` the real claim forever) | config lock, manifest re-read inside it | +| manifest change (other tenant) racing the barrier | — | — | serialised by the cross-tenant lock; if it lands between steps 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 | + +**Adding a new account today.** No verb on Claustrum master writes our provider block +(`mint-handle` prints a handle; `migrate-opencode` writes an OpenCode-shaped entry even under +`--serve-by anthropic-auth`; Claustrum's follow-up plugin tooling writes our block but takes a +plugin-**exported** file, so it migrates accounts we already hold). Two paths: + +- direct, once it lands: `ck auth bind --serve anthropic-auth --label