Joint design round with the anthropic-auth plugin half. This reframes #4 from vault-watches-source to vault-IS-source: instead of the vault mirroring a credential the harness owns, the plugin migrates custody in and serves through claustrum.
The treadmill exists because two parties hold copies of one token family. Measured end to end tonight on this host:
19:26:45Z harness rewrites auth.json (rotation)
19:27:34Z consumer 401 (49s later)
19:32:35Z vault refresh -> invalid_grant (301s after the report)
With no mirror there is no second copy, so there is nothing for a rotation to orphan. The failure mode is removed by construction rather than defended against.
Vault-side design doc: docs/claustrum-mode-serve-contract-design.md (this branch). Plugin half: anthropic-auth/.opencode/specs/2026-08-24-claustrum-mode-plugin-half-design.md.
Nothing is built. Both halves are designs, and these 12 questions are the ones neither half can answer alone.
What we measured rather than estimated
spawn only (ck-auth help) median 0ms n=12, 0 failures
offline store: open + lease + decrypt + list median 1ms n=10, 0 failures
live loopback admin round-trip (ck-auth status) median 42ms n=12, 0 failures
The store read is 1ms — it is not the cost. The 42ms figure is an admin round-trip that includes a master-key challenge the plugin path never performs and excludes the handle-resolve it always does, so it is neither an upper nor a lower bound on serve latency. The plugin path is unmeasured because it does not exist; §3.3 specifies the benchmark that must run first.
Also measured, on antigravity:google — the one credential that lives long enough to exercise the TTL refresh path:
383 refresh_commit intervals · median 58.5 min · 382/383 inside a 57-63 min band
For oauth:anthropic that path has never fired and could not have: an 8h token means the consumer's 120s TTL demand triggers at 7.97h, and the longest credential lifetime ever recorded is 3.95h. Post-migration it becomes live for the first time.
The 12 decisions
Hot path
- The refresh stall. Refresh is synchronous inside
get: when a token crosses the 60s staleness skew, the caller waits for an upstream OAuth round-trip. Single-flight means N concurrent callers produce one exchange — and all N wait. On a model-call path that is a user-visible stall once per token lifetime. min_ttl_ms, a proactive sweep, or an accepted stall? Today it is the third by omission.
- The limiter mutex.
check_limiter takes a single global async lock on every get, before handle resolution. Invisible for a 60s poller; a shared contention point for a bursty hot-path consumer.
- Bound refresh TRIGGERING, not just
force_refresh. min_ttl_ms reaches the same upstream exchange and is caller-supplied with no clamp anywhere between GetParams and is_stale. min_ttl = 24h against an 8h token makes every get refresh — force_refresh with no boolean to grep for. A TTL floor is self-limiting only while min_ttl < token_lifetime. Clamp against the record's own lifetime, and does the plugin get force_refresh at all?
Two consumers on one family
- Attribution — for writes as well as reports.
actor names a path, not an identity. Field instance from tonight: two imports three minutes apart, labels offline-cli and route-admin, three candidate actors, and both attributions arrived as testimony over chat — neither came from the chain. The second write carried the same label as this seat's own re-seals, so absent certainty about his own actions the reader could not have excluded himself as the author of a write to a credential in his custody. A field that cannot exclude the reader is not attribution. module_id already arrives at route-bind and the main consumer already attaches consumer_identity on every route.open — the identity is reachable in production today and the report path does not look.
- Non-refreshable latch. "Consumer reports must never latch" is the right invariant and is not today's behaviour: a report against a static record latches immediately. Tonight exercised only the refreshable arm. Change it — or scope the invariant and say what a static api-key does instead, since there is genuinely no recovery the vault can attempt.
- Publish refresh outcomes. Without them a migrated account degrades silently and the plugin keeps routing to an account the vault knows is failing. The facts are already in
auth_events (kind, provider_status, detail) and absent from the read surface. Decide explicitly what replaces the plugin's 429 backoff — the vault has no backoff concept at all.
- Import authorization. Today's import authenticates with the master key, audits as
route-admin, and raises an admin_write alarm. A plugin cannot hold the master key; if it could, claustrum mode would be worse than the mirror it replaces. Write-capable handle, or single-use login-completion capability?
Migration
- Anthropic adapter parity. The adapter persists rotated refresh tokens correctly (verified at source, both arms unit-tested) but has zero successful commits all-time — tonight was its first execution ever, in the failure direction. Untested, not broken. Does migration block on a demonstrated successful refresh against a plugin-sourced grant?
- Q1b assertion. The placeholder-boot gate must assert on observed authenticated behaviour, not a clean boot: a non-decoding record is silently filtered and the provider then reads unauthenticated. A broken placeholder produces the same clean boot as a working one.
- Fault injection. The treadmill is currently the only thing exercising every credential-failure path in production, on both sides. Killing it does not make those paths correct — it makes them unexercised, and one of them (the TTL refresh path for anthropic) starts running for the first time at the same moment.
- The displaced
auth.json signal. If claustrum writes the legacy view, auth.json's mtime stops recording upstream rotations and starts recording claustrum's own writes — it will look most recently rotated exactly when the vault was busiest. That mtime was the discriminator for tonight's measurement. Proposal: append a view_write audit row per write, which is strictly better than what exists (mtime holds only the last rotation; the chain holds all of them).
- Drain contract. Does claustrum owe consumers an explicit pre-restart drain signal, or is
--drain-ms sufficient by convention? A reload is planned absence the daemon knows about and the consumer does not.
Two things the design already settles
Fail-open, and the distinction that matters most. A plugin cache may substitute for an absent vault; it may never override a refusing one. Unreachable and refusing arrive over the same wire, and serving a cached token past a needs_reauth converts one clean failure into a retry storm. Cache the access token to its own expires_at_ms and no longer — never the refresh token, never an invented TTL.
Handles are shareable across processes. No pid, no owner, no lease: per-credential-id, not per-process, and acquisition needs no cross-process locking. Consequence: possession is authorization, so handles must stay out of dumps, logs and shared state — a consumer obligation, not extra caution.
Rollback is asymmetric
| row |
flag-off cost |
| fallback accounts |
free — the plugin resumes reading its sidecar |
| main account |
requires restoring a real credential into the host store; skipping it leaves the provider unauthenticated until interactive re-login |
"The mode is a flag" is true for fallback and false for main. A rollback plan that does not say which row it means is not a rollback plan.
Joint design round with the anthropic-auth plugin half. This reframes #4 from vault-watches-source to vault-IS-source: instead of the vault mirroring a credential the harness owns, the plugin migrates custody in and serves through claustrum.
The treadmill exists because two parties hold copies of one token family. Measured end to end tonight on this host:
With no mirror there is no second copy, so there is nothing for a rotation to orphan. The failure mode is removed by construction rather than defended against.
Vault-side design doc:
docs/claustrum-mode-serve-contract-design.md(this branch). Plugin half:anthropic-auth/.opencode/specs/2026-08-24-claustrum-mode-plugin-half-design.md.Nothing is built. Both halves are designs, and these 12 questions are the ones neither half can answer alone.
What we measured rather than estimated
The store read is 1ms — it is not the cost. The 42ms figure is an admin round-trip that includes a master-key challenge the plugin path never performs and excludes the handle-resolve it always does, so it is neither an upper nor a lower bound on serve latency. The plugin path is unmeasured because it does not exist; §3.3 specifies the benchmark that must run first.
Also measured, on
antigravity:google— the one credential that lives long enough to exercise the TTL refresh path:For
oauth:anthropicthat path has never fired and could not have: an 8h token means the consumer's 120s TTL demand triggers at 7.97h, and the longest credential lifetime ever recorded is 3.95h. Post-migration it becomes live for the first time.The 12 decisions
Hot path
get: when a token crosses the 60s staleness skew, the caller waits for an upstream OAuth round-trip. Single-flight means N concurrent callers produce one exchange — and all N wait. On a model-call path that is a user-visible stall once per token lifetime.min_ttl_ms, a proactive sweep, or an accepted stall? Today it is the third by omission.check_limitertakes a single global async lock on everyget, before handle resolution. Invisible for a 60s poller; a shared contention point for a bursty hot-path consumer.force_refresh.min_ttl_msreaches the same upstream exchange and is caller-supplied with no clamp anywhere betweenGetParamsandis_stale.min_ttl = 24hagainst an 8h token makes everygetrefresh —force_refreshwith no boolean to grep for. A TTL floor is self-limiting only whilemin_ttl < token_lifetime. Clamp against the record's own lifetime, and does the plugin getforce_refreshat all?Two consumers on one family
actornames a path, not an identity. Field instance from tonight: two imports three minutes apart, labelsoffline-cliandroute-admin, three candidate actors, and both attributions arrived as testimony over chat — neither came from the chain. The second write carried the same label as this seat's own re-seals, so absent certainty about his own actions the reader could not have excluded himself as the author of a write to a credential in his custody. A field that cannot exclude the reader is not attribution.module_idalready arrives at route-bind and the main consumer already attachesconsumer_identityon everyroute.open— the identity is reachable in production today and the report path does not look.auth_events(kind,provider_status,detail) and absent from the read surface. Decide explicitly what replaces the plugin's 429 backoff — the vault has no backoff concept at all.route-admin, and raises anadmin_writealarm. A plugin cannot hold the master key; if it could, claustrum mode would be worse than the mirror it replaces. Write-capable handle, or single-use login-completion capability?Migration
auth.jsonsignal. If claustrum writes the legacy view,auth.json's mtime stops recording upstream rotations and starts recording claustrum's own writes — it will look most recently rotated exactly when the vault was busiest. That mtime was the discriminator for tonight's measurement. Proposal: append aview_writeaudit row per write, which is strictly better than what exists (mtime holds only the last rotation; the chain holds all of them).--drain-mssufficient by convention? A reload is planned absence the daemon knows about and the consumer does not.Two things the design already settles
Fail-open, and the distinction that matters most. A plugin cache may substitute for an absent vault; it may never override a refusing one. Unreachable and refusing arrive over the same wire, and serving a cached token past a
needs_reauthconverts one clean failure into a retry storm. Cache the access token to its ownexpires_at_msand no longer — never the refresh token, never an invented TTL.Handles are shareable across processes. No pid, no owner, no lease: per-credential-id, not per-process, and acquisition needs no cross-process locking. Consequence: possession is authorization, so handles must stay out of dumps, logs and shared state — a consumer obligation, not extra caution.
Rollback is asymmetric
"The mode is a flag" is true for fallback and false for main. A rollback plan that does not say which row it means is not a rollback plan.