Skip to content

fix(custody): credential-presence gates and reported-failed version refusal - #232

Closed
iceteaSA wants to merge 11 commits into
cortexkit:mainfrom
iceteaSA:fix/custody-cutover
Closed

iceteaSA wants to merge 11 commits into
cortexkit:mainfrom
iceteaSA:fix/custody-cutover

Conversation

@iceteaSA

@iceteaSA iceteaSA commented Sep 17, 2026 •

Copy link
Copy Markdown
Contributor

Follow-up to #231, found by sweeping the class that PR's last commit belonged to.

The class

A vault-served account's local auth slot holds only the provider tombstone, so account.access === '' while the account is healthy and serving. Every check that used local credential bytes as a proxy for usability became wrong the moment custody went live. Before custody every account carried real local bytes, so the proxy was sound — this is a precondition change turning dead branches live, not a regression.

A read-only census over both packages examined 26 candidate gates: 6 live, 5 inert, 15 correct. Each inert verdict had to name the specific source-traced precondition that blocks the branch, or be downgraded to live-suspected.

Commit 1 — six presence gates

site was effect under custody
accounts.ts canUseCachedQuotaAfterRefreshError account.access && expires > now cached-quota recovery blocked after a transient vault failure
index.ts main profile hydration mainAccessToken && tier metadata never hydrates
index.ts fallback sidebar profile !account.access hydrated profile never reaches the sidebar
index.ts main sidebar profile mainAccessToken && mainState same for main
index.ts /claude-quota auth.type === 'oauth' && auth.access main skipped entirely — while the next line already reads mainServedAccessToken ?? auth.access
index.ts killswitch eager refresh Boolean(a.access) vault-served fallbacks excluded from the spend guard's pre-check refresh

All widen to local access || live vault binding. None admits an account with no credential anywhere. All six are reporting/refresh gates — no routing or serving eligibility rule is loosened, and the assertNotCustodyTombstone wire-refusal sites are untouched.

The killswitch one is the only non-cosmetic member: a spend guard that skips its own freshness step degrades silently and in the expensive direction. Scoped honestly — the background poller still keeps that quota current, so the exposure is "may evaluate without forcing a fresh read", not "evaluates on stale data".

Commit 2 — reported-failed version refusal

Cross-family drift review caught that liveMainVaultAccess (added in commit 1) hand-rolled its sibling resolveClaustrumAccess and dropped the sibling's stale-version guard. Reviewer probe: boot vault-served main → 401 → report_auth_failure at version N → /claude-quota still sends that bearer. It now delegates, inheriting both the guard and the warm schedule.

Implementing that surfaced a second, different defect: mainServedAccessToken was never cleared. A bearer whose 401 we had already reported to the vault stayed the preferred main token for the process lifetime, with no reconciliation path — local belief diverging from what the vault was told. It is now cleared on a successful report only; clearing on a suppressed or failed report would make local state believe the vault knows something it was never told.

/claude-quota also had to move from ?? to ||, or an empty served token short-circuits and the fix is inert on that path.

Verification

Per-hunk revert evidence, one proof per site — with six hunks in play a whole-diff revert only shows the batch is load-bearing. One site's first two attempts produced a test that passed with its own fix reverted; the implementer reported that rather than shipping it green, and the assertion moved from end-state to action:

refreshAllFallbacks receives ["killswitch-vault-fallback"]   with the fix
refreshAllFallbacks receives []                              hunk reverted alone

Commit 2's three proofs: warm callbacks 1 → 0, quota authorizations containing the dead bearer, and quota authorizations empty when the ??/|| chain is reverted.

Gates on the rebased tree: core 200, opencode 1889, pi 114, e2e 36, typecheck 0, biome 0 across 189 files.

Deliberate scope: get-before-report (re-fetch and retry on a vault-served 401 before reporting) is not in this PR. It is a larger behavioural change to a path a peer seat is currently measuring, and it deserves its own review.


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


Summary by cubic

Fixes vault-served accounts being skipped by credential-presence checks, stops reusing a main vault bearer after its 401 was reported, and makes the Claustrum golden-pin check fail on drift or unchecked ancestry.

Bug Fixes

  • Six presence gates now admit a live vault binding alongside local access; none admits an account with no credential anywhere.
  • Cached-quota admission rechecks the vault binding at failure time, so a binding that disappears mid-refresh isn't admitted afterward.
  • mainServedAccessToken clears only after a successful auth-failure report; quota and profile paths use || instead of ?? so an empty served token doesn't short-circuit.
  • liveMainVaultAccess delegates to resolveClaustrumAccess, inheriting the stale-version refusal and warm schedule.
  • Killswitch eager refresh now includes vault-served fallbacks, so spend protection evaluates on fresh quota.
  • Tripwire tests pin the vault-facing refresh TTL at 270 minutes and the config override floor at 240; lowering either lengthens the vault's rotation period and can trip the vault operator's stall alarm without advance notice.
  • The golden Claustrum fixture now tracks cortexkit/claustrum, and the checker fails when the pinned ref no longer descends from the upstream default branch or content can't be verified.

Written for commit 2df9d0e. Summary will update on new commits.

Review in cubic

RetriggerConfidence Score: 4/5

The PR is not yet safe to merge because cached quota can still cross identities when a non-scoped vault binding begins serving a replacement account.

Findings

  1. P1 Cached quota crosses identities ▶
Summary

This PR updates custody-aware credential gates so vault-served accounts participate in profile hydration, quota refresh, cached-quota recovery, and killswitch checks. It also refuses already-reported vault credential versions, clears successfully reported main credentials, centralizes the vault refresh TTL, and strengthens Claustrum fixture validation.

  • Treats live vault bindings as credential presence without admitting accounts lacking both local and vault credentials.
  • Prevents successful failure reports from leaving the rejected main bearer preferred in process state.
  • Adds custody-specific regression coverage for profile, quota, killswitch, and stale-bearer behavior.
  • Makes Claustrum golden-file checks fail closed when content or upstream ancestry cannot be verified.
Diagram
%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[Account credential state] --> B{Local access available?}
    B -->|Yes| C[Use local credential]
    B -->|No| D{Live vault binding?}
    D -->|No| E[Exclude account]
    D -->|Yes| F[Resolve current vault credential]
    F --> G{Version previously reported failed?}
    G -->|Yes| E
    G -->|No| H[Hydrate profile and refresh quota]
    H --> I{Request returns auth failure?}
    I -->|No| J[Keep account eligible under quota policy]
    I -->|Yes| K[Report failed version to vault]
    K --> L{Report succeeded?}
    L -->|Yes| M[Clear served main token]
    L -->|No| N[Retain local state because vault was not notified]
Loading

Reviews (2) · Last reviewed commit: "test(core): satisfy the tightened fetchI..."

@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 4 files

You’re at about 91% of the monthly reviewed-line limit. You may want to disable incremental reviews to conserve quota. Reviews will continue until that limit is exceeded. If you need help avoiding interruptions, please contact contact@cubic.dev.

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread packages/core/src/accounts.ts
Comment thread packages/opencode/src/tests/index.test.ts Outdated
Comment thread packages/opencode/src/tests/index.test.ts 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.

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

You’re at about 91% of the monthly reviewed-line limit. You may want to disable incremental reviews to conserve quota. Reviews will continue until that limit is exceeded. If you need help avoiding interruptions, please contact contact@cubic.dev.

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread packages/opencode/src/tests/accounts.test.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).

You’re at about 92% of the monthly reviewed-line limit. You may want to disable incremental reviews to conserve quota. Reviews will continue until that limit is exceeded. If you need help avoiding interruptions, please contact contact@cubic.dev.

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

Re-trigger cubic

Comment thread packages/core/src/tests/accounts-persistence.test.ts Outdated
Six gates treated local credential bytes as a proxy for usability. A vault-served account's local slot is the provider tombstone, so quota recovery, profile hydration, /claude-quota and the killswitch's eager refresh all skipped healthy accounts once custody emptied that slot. Each site now admits a live vault binding alongside local access; none admits an account with no credential anywhere.
liveMainVaultAccess hand-rolled its sibling and dropped the sibling's stale-version guard, so main profile hydration and /claude-quota could bearer a version already reported auth-failed. It now delegates to resolveClaustrumAccess, inheriting both the guard and the warm schedule. mainServedAccessToken is cleared on a successful main report only: a suppressed or failed report tells the vault nothing, so local belief must not diverge from what the vault received.
// the local credential is live or a live Claustrum binding serves it.
Boolean(
(account.access && account.expires && account.expires > now) ||
vaultServed,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Cached quota crosses identities

When a non-scoped vault binding starts serving a different Anthropic identity, the account UUID is updated without clearing the existing quota snapshot. If the next quota refresh fails transiently, this new vault-presence branch accepts that old snapshot without checking its identity or authentication lineage. The replacement account can therefore inherit the previous account’s quota decision, either admitting an exhausted account or incorrectly blocking a healthy one. Clear or identity-fence the persisted snapshot before using vault presence to justify cached-quota recovery.

Knowledge Base Used:

v1.23.0 types fetchImpl as typeof fetch, which requires preconnect. The
bare async stub no longer satisfies it.
@iceteaSA

Copy link
Copy Markdown
Contributor Author

@ualtinok, following up: re-checked against current main (4a01612, v1.23.0), and this is still needed. It rebases with no conflicts, and its production diff is 177 lines that main still lacks across core/src/accounts.ts, opencode/src/index.ts and scripts/check-claustrum-golden.ts.

Where the defects are on main now:

  • Vault refresh TTL is still worked out inline at packages/opencode/src/index.ts:2778 (getRefreshBeforeExpiryMs(storage) + 30 * 60_000). This PR moves it into a shared getVaultRefreshMinTtlMs helper and adds a tripwire test that fails if the 30-minute margin changes. Claustrum's rotation-stall alarm is tuned to this value.
  • A stale fallback can be admitted during a refresh: in getUsableFallbackAccounts (packages/core/src/accounts.ts:4633) the refresh error handler still reuses a vaultServed value it read before the await. An account whose vault access went away during the refresh is still admitted to routing on cached quota.
  • Credential-presence checks: several sites still decide whether an account has credentials by whether access is non-empty. A tombstoned vault account has access: "", so these checks treat it as having no credentials. (fix(custody): manifest shape and apiKey on refusing loader returns #231 fixed the sidebar case; this PR covers the rest.)
  • Main can send a credential already reported as failed: a main-path vault version that has been reported failed can still be sent.
  • Golden fixture checker: it now points at cortexkit/claustrum instead of a stale fork and fails if the pinned commit isn't an ancestor.

CI is green on the current head. #233 builds on this branch.

@iceteaSA

Copy link
Copy Markdown
Contributor Author

Closing: c2f3428 removed the handle cache this PR was built on (claustrumLastReportedVersion, resolveClaustrumAccess, getVaultRefreshMinTtlMs are all gone), and main now handles scoped custody at the killswitch eager refresh (index.ts:8048). Three truthiness gates on access from the same census are still on main, and under scoped custody the tombstoned rows carry empty access: /claude-quota main (index.ts:3243, auth.type === 'oauth' && auth.access), cached-quota recovery after a transient quota error (accounts.ts:3950), and main profile hydration (mainAccessToken &&, index.ts:1565-1597, depending on how mainAccessToken resolves under scoped custody). I haven't verified whether they misbehave under scoped custody. If one does, I'll open a small PR with a failing test.

@iceteaSA iceteaSA closed this Sep 24, 2026
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.

1 participant