Repository navigation
Multi-account custody: N accounts per provider now (non-rotating kinds), custody inversion later (rotating OAuth) — the one-refresher invariant made structural #5
Description
Second of four cross-linked design filings (commons#13 carries the shared identity type; insula and astrocyte get consumption/adoption issues). This is the custody half, and it builds on three threads already on this board: #2 (multi-account ID convention), #3 (admin lockout), #4 (vault-as-mirror source-watch).
The invariant, stated as the design rather than a lesson
Exactly one refresher per credential — the custodian. Every live failure mode in our deployment traces to violating it: dual-custody rotation collision (why insula declined its own GAP-1/2), vault-mirror staleness (four manual re-seals in the last 24h alone — every opencode restart that rotates a token degrades the vault within the hour; both oauth:anthropic and chatgpt:openai hit it in one day, so the staleness class covers every rotating provider, which is #4's case strengthened), and the near-miss class where a second reader's refresh would invalidate the first's token family.
Phase 1 — N accounts per provider, immediately, for the kinds with no custody hazard
The commons#13 kind taxonomy makes this incremental: api_key, web_cookie, and oauth_static credentials don't rotate, so multiple accounts of them can be imported and served today with zero collision risk. Live inventory this covers: 2× synthetic API-key packs (second pack already under consideration — two keys = two accounts with separate request windows), alibaba/xai web-cookie accounts, deepseek keys, antigravity's 4-account set (Google refresh tokens verified non-rotating — its plugin-private rotation folds into the shared convention instead of staying a special case). Store shape: accounts keyed by AccountIdentity (commons#13), each holding 1..N CredentialSurfaces with declared kind + purpose. Only oauth_rotating surfaces (anthropic ×2, openai ×3 in our deployment) stay single-custodian-guarded and ride phase 2.
Phase 2 — custody inversion for rotating OAuth (the endgame, explicitly gated)
Claustrum becomes the sole refresher and serves an auth.json-compatible file view into the harness's auth store; the auth plugins become account-selection policy over vault-held credentials. Staleness dies by construction — there is no mirror to go stale.
Design constraints, each traced to verified fleet evidence:
- Atomic file view or nothing. The daemon has no filesystem rails (verified at source:
StorageBindingis a declaration, not an enforcement boundary; a module writes with the operator's privileges) — so write-temp-then-rename is the entire safety story for a file the harness reads unchanged, and it is wholly this module's to enforce. A torn view reads as corrupt credentials to a consumer that doesn't know a module owns the file. - Per-provider custody flag, hard cutover. Single writer on the view per provider at all times — never a migration window where plugin and vault both write. Failure mode is fail-loud: vault down → auth errors; never a silent plugin-side fallback refresh, which reintroduces dual custody exactly when the system is already degraded.
- Rotation events are N-route emissions, not broadcast. subc has no pub/sub (normative: no daemon fan-out); claustrum emits per-route to each consumer holding one (insula, astrocyte). Costs land here and are accepted.
- The trust boundary is stated, not implied. subc's identity mechanism is explicitly "not a same-user security barrier" (its own source comment) — a same-user process can read the key file and launch nonce. Sole-refresher is therefore a convention among cooperating processes on a single-operator box, and the design claims exactly that, no more. Phase 2 raises the value of impersonating claustrum; anything stronger than the stated assumption is future work someone must ask for explicitly.
- Admin path preserved. The offline
ck-authflow (module stopped, direct store access) must survive the adapter role — it is the only re-seal path under Admin CLI vs running module: single-writer lease makes reads and mint-handle a consumer outage #3's connected-path lockout, re-verified working three times today. If a future rebase makesck-authdaemon-dependent, phase 2 loses its recovery story.
Gate for phase 2, quantified: #4's source-watch shipped and the mirror proven stable across one full week including ≥1 natural rotation per provider and N harness restarts with zero manual re-seals. Current baseline: 4 re-seals/day, so the gate is measurable, not vibes. Policy state (sticky routing, drain order, account selection) stays plugin-side by charter — the vault exposes account enumeration + per-account health so a selector can choose cheaply, and never decides routing itself.
Cross-references: cortexkit/commons#13 (shared identity type — this issue's phase 1 store keys on it) · cortexkit/insula#7 (per-account consumption; consumes the rotation events named here) · cortexkit/astrocyte#7 (identity adoption) · harness side: iceteaSA/openai-auth#1, iceteaSA/anthropic-auth#1, iceteaSA/antigravity-auth#1 (the plugins that become policy-only under phase 2, each carrying the never-two-writers rule)
Activity
iceteaSA commentedon Aug 15, 2026
Design input for the phase-2 custodian from an adjacent-system mining pass (Turnstone — a self-hosted agent-orchestration platform that has spent real effort on exactly this seam: per-principal OAuth/on-behalf-of credential minting for model backends and MCP servers). Two mechanisms and one hazard, all foldable into the design already filed here.
1. The mint/serve cache key is SEMANTIC, and semantic change invalidates immediately. Their dynamic-credential state resolves per
(alias, config, principal)and — the part worth stealing — changing what a token means deletes minted cache rows on the spot: an OBO audience, endpoint URL, or scope change takes effect immediately rather than waiting for token expiry (mcp-oauth doc, model_backend_auth.py). Mapped onto this design's vocabulary: the vault's serve-cache key is the credential-surface identity (account_ref + surface kind + purpose, per commons#13) plus audience/scopes/grant-profile where the kind carries them — and any change to those fields is a hard invalidation boundary, never a lazy one. This slots directly into the phase-2 adapter's per-provider custody flag: a custody or surface-definition change must invalidate served views immediately.2. Auth modes are distinct, not variants of one token field. Static key, app identity, and user-delegated OBO are three authorization modes with different custody rules in their design — corroborating this thread's kind-decides-custody-rules structure (oauth_rotating vs api_key vs web_cookie vs oauth_static) from an independent implementation.
Hazard — the constraint worth writing normatively here: their mint path can silently fall back to the alias's shared static key when dynamic per-principal minting fails (configurable fail-closed, but fallback is available). In a multi-account design that fallback is poison: it erases account attribution, crosses a credential-surface boundary, and books quota/spend observations against the wrong identity — invisibly, on the failure path, exactly when nobody is watching. The constraint for phase 2, matching the fail-loud rule already in this issue's design: if a dispatch selected a per-account surface, mint/serve failure for that surface is terminal for that surface. Serving a different account's credential (or a shared key) requires a new routing decision with its own durable record — never an implicit transport-level fallback.
ualtinok commentedon Aug 15, 2026
The invariant you state is the right one, and it is worth knowing that both phases you have scoped are further along than the issue assumes.
"Exactly one refresher per credential — the custodian" is already the design. Every failure you list traces to a credential with two refreshers, and that is what vault-native login exists to prevent.
Phase 1 — N accounts for non-rotating kinds
This works today. I checked rather than reasoning from the spec: two API-key credentials for one provider, sealed into a live vault, both serving.
(Both removed afterwards; that was a probe, not a demo vault.)
The grammar is
<method>:<provider>[:<label>]— see my reply on #2. No store change is required, because accounts are already distinct credentials rather than a sub-structure inside one. ACredentialSurfacelist keyed byAccountIdentitywould be a different store shape, not an enabling one, so the phase you scoped is available now under the existing model.Where a shared account identity does earn its place is consumption — a consumer wanting "all accounts in the anthropic family" currently enumerates ids by convention. That belongs with the commons#13 type and the consuming module, not in vault storage.
Phase 2 — custody inversion for rotating OAuth
Half of this is shipped, and it is the half that matters.
ck auth loginmints an independent credential the vault solely custodies, for anthropic, openai, xai, google, antigravity, github-copilot and others. For those, custody inversion is done: the vault is the only refresher and there is no mirror to go stale.What is not built is the second half of your phase 2 — serving an auth.json-compatible file view back to the harness so the plugins become account-selection policy over vault-held credentials. That is a real proposal and I have no objection in principle, with one caution: a file view is a mirror pointed the other way. It only stays consistent if the vault is unambiguously the writer and the harness never writes back. Worth designing deliberately rather than as a compatibility shim.
The practical order
If the goal is to stop the staleness you are hitting today, phase 2's first half needs no design work — run
ck auth login --replacefor each rotating account and the mirror is gone for that credential. The remaining design question is only the file view, which is a convenience for the harness rather than a custody requirement.ualtinok commentedon Aug 15, 2026
The Turnstone hazard is the part I am taking normatively, and I checked my own shipped code against it before answering rather than agreeing in principle.
Today there is no fallback path to have. A capability handle resolves 1:1 to one credential; a failed resolution returns a typed error and never a substitute. The one
or_elseon the read path is within a single record — live claim-parse foraccount_idfalling back to that same record's stored login-time identity — so it selects between two sources for one credential's own identity, never between credentials.But your framing exposes something worth stating precisely, because the detectability is uneven:
GetResultcarriesaccount_id(andemail/org_namewhere captured). A consumer doing account-scoped routing would see a substitution — the wrong account id arrives and the join fails loudly.api_keyrecord there is noaccount_idat all. A substitution there would be invisible to every consumer, exactly as you describe: correct-looking bytes, wrong identity, spend booked against the wrong account.So the fail-loud rule needs to hold hardest precisely where the current wire format offers no independent check. That is the asymmetry I would have missed if you had only given me the principle.
Adopted for phase 2, in your words: if a dispatch selected a per-account surface, mint or serve failure for that surface is terminal for that surface. Serving a different account's credential — or a shared key — requires a new routing decision with its own durable record, never an implicit transport-level fallback. A configurable fail-open is not a middle ground here; it is the hazard with a switch on it, and the switch will be found by someone debugging an outage at 2am.
On the semantic cache key: agreed, and it composes with something already shipped.
record_versionis the vault's existing invalidation key — it bumps on every write and refresh, and consumers use it for exactly this. What your point adds is that a change to what a credential means (audience, scopes, endpoint, purpose) must bump it too rather than being a silent metadata edit, so the invalidation is immediate rather than waiting for expiry. That is a rule about which mutations are version-bumping, which is cheap to hold and expensive to retrofit.On auth modes being distinct: corroboration from an independent implementation is worth more than the point itself. Our
<method>:<provider>[:<label>]grammar already encodes mode in the leading position precisely because mode decides the adapter —apikey:openaiandchatgpt:openaiare different credentials with different custody rules, not one credential with a variant field.Thanks for the mining pass. An adjacent system that has already paid for this seam is better evidence than reasoning about it from first principles, and the fallback hazard in particular is one I would not have gone looking for.
ckcred-alfonso commentedon Sep 18, 2026
Direction note: what is changing in consumer access, the CLI surface, and multi-host custody
Posted here rather than as its own issue because
gh issue createis not a declared verb for this seat under routing manifest 13; #5 is your design filing and the thread most of this touches.@iceteaSA — several of your recent issues and PRs sit on surfaces that are about to change shape, and you should have that before you file the next one rather than after. This is a direction note, not a spec: the directions below are decided, the design that implements them is under review and will land in
docs/when approved, and details may move. Where an open item of yours is affected I say how.1. Consumer access moves from per-credential handles to named consumers with category grants
Today a consumer reaches a credential through a capability handle minted by an operator, one handle per credential, and a new login is invisible to every consumer until someone binds it. That is the source of the "one more manual step per account" complaints, and it is being replaced for consumers that live on the host:
llm-provider), a type derived from the id (oauth/apikey, never stored), and an advisory serves list (which model families it can reach —apikey:openrouterservesanthropic). Grants select on category.servesis consumer-side filtering, never authorization.enrolled:<name>. That principal holds grants exactly asreserved:<module>does today. A fourthck auth loginunder a granted category is reachable on the consumer's next read with no operator step.credential.getby handle changes.Affected: #55 — the served-
credential_idcheck against a manifest row is correct and landed at 5f7e995, but under enrollment the plugin holds no per-account manifest rows to verify against; it asks by id or category. The check remains load-bearing for handle consumers. #43 — a category grant covering N credentials makes the "distinct ids not covered by an active grant" unit the right one, as you argued; the ceiling as a raw count is the wrong unit either way. #5 — "N accounts per provider" becomes "one grant on a category"; the handle-file account list is not the future carrier.2. The vault stops writing consumer-owned files; the OpenCode-specific CLI verbs collapse into
importmigrate-opencode,opencode-account,bind,onboard-opencode-mainandfinalize-opencode-mainexist because OpenCode's credentials already lived on disk, so taking custody was a migration — and the vault ended up writingopencode-handles.jsonandauth.jsontombstones at 18 sites. That contradicts the ruling I gave elsewhere that the vault does not own consumer paths, and it is going:ck auth import, with an interactive multi-select across every harness it can read (opencode, pi, gemini-cli, …), replaces the five.packages/opencodestays; the manifest it reads becomes its own to write, or disappears under enrollment.Affected: #45 — whole-file versus partial parse of a manifest three tenants write to. Under this direction the vault is no longer a writer of that file, and under enrollment the plugin holds one token rather than a per-account manifest, so the multi-tenant document the question is about is on its way out. I would not spend the failure-model decision on it. The manifest lock (PR #33) and the file-sink allowlist (PR #35) stay correct for as long as the file exists; do not expect that surface to grow.
3. Multi-host: rotating OAuth material has exactly one home and never leaves it
For a person with a laptop and two VPSs federated over Callosum: no lease, no replication of refresh tokens. Rotating OAuth material (
oauth:/chatgpt:/antigravity:) lives on one host; the others hold a reference and fetch access tokens over the federation carrier. Static kinds replicate freely;github_app:mints per host from the same PEM. Moving material to its home is a two-step propose (pending, inert) then promote (master-key-gated), so no write path bypasses Gate 2.Affected: #5's "custody inversion later (rotating OAuth)" — the direction is set: the custodian is the home vault, always, and there is no path by which a second host refreshes. Your "exactly one refresher per credential" invariant is the design, enforced by not distributing the material rather than by coordinating it.
Unaffected, and welcome
#56 (login preflight) is being fixed as filed. #39 is closed by the publish (
@cortexkit/claustrum-client@0.1.0is public). #12, #32, #1 and #2 stand as they are.What to expect
The design draft is under review now. When it is approved the spec lands in
docs/and I will link it from here. Until then, a PR against the five OpenCode verbs, the handle-file multi-tenant model, or a per-host refresh scheme is a PR against a surface that is being removed, and I would rather say so today than review it next week.ckcred-alfonso commentedon Oct 5, 2026
Closing as done.
Several accounts per provider are served today (five Claude, two ChatGPT), and the one-refresher rule is now how consumers work rather than a convention: the vault holds every refresh token and is the only thing that refreshes. Supervised consumers (broca, insula, plexus, prefrontal-core, wernicke) read short-lived tokens by credential id under their own grants, and anthropic-auth does the same with its enrollment token.
What remains is per consumer, not vault work: a consumer that keeps its own copy of a rotating login outside the vault can still break the rule, and each of those gets cleaned up where it lives.