Skip to content

fix(vault): write/admin scope split — schema mutations, pack-apply, and MCP vault-info require vault:admin (#134 A.1–A.2) - #235

Merged
unforced merged 3 commits into
ParachuteComputer:mainfrom
unforcedagi:fix/write-admin-scope-split
Aug 11, 2026
Merged

fix(vault): write/admin scope split — schema mutations, pack-apply, and MCP vault-info require vault:admin (#134 A.1–A.2)#235
unforced merged 3 commits into
ParachuteComputer:mainfrom
unforcedagi:fix/write-admin-scope-split

Conversation

@unforcedagi

Copy link
Copy Markdown
Collaborator

Closes the first slice (A.1–A.2) of #134: on hosted, a limited vault:write token could perform admin-only operations.

What changes

  • Tag-schema mutations require vault:admin (PUT/DELETE/rename/merge/prune on /api/tags/*): the REST gate now checks granted scopes via hasScopeForVault, not the collapsed permission, at the single pre-dispatch chokepoint every /api/* route funnels through.
  • POST /api/packs/:name requires vault:admin — found during review, not in the original fix: pack-apply reaches the same upsertTagRecord mutation the tags door now gates, so a write token could overwrite an owner's curated tag schemas wholesale. The gate predicate is character-identical to the dispatch predicate, so no routing shape reaches the handler ungated (11 adversarial shapes probed, all refused).
  • Console fallout fixed: the console's "Add the Surface Starter guide" button is the one production caller of pack-apply and minted write. It now mints a 60s admin token stamped with a new non-platform client_id (parachute-packs) — admin verb + the first-party id would have satisfied both halves of the vault's platform gate (internalForbidden), making the token latently platform-tier. The new id passes the packs scope gate but is refused on /api/internal/*, pinned by test.
  • MCP vault-info description edits require admin; the REST PATCH /api/vault door intentionally keeps description at write tier for wire-contract parity with the upstream vault (same asymmetry exists there) — documented as KNOWN GAP comments at both sites. So the honest claim is: MCP door tightened; REST parity gap tracked, to be closed in lockstep with upstream.

Review trail

Independent adversarial review demonstrated both the pack-apply escalation and the REST parity gap empirically before the fixes; a second delta review verified the fixes (verdict: ship) and requested the client-id hardening, applied here.

Tests

  • Write-token refusal pinned with state read-back: 403 + insufficient_scope + required_scope: vault:admin, then byte-for-byte proof the owner's curated schema survived (conformance.test.ts, packs.test.ts).
  • Platform-gate refusal of the new client id pinned in internal-config.test.ts.
  • Full vault suite serial: 26 files, 411 passed | 1 todo. Identity pack-button suite: 8/8. Typecheck clean in both workers.

🤖 Generated with Claude Code

unforcedagi and others added 3 commits August 11, 2026 13:29
…fo description require vault:admin (cloud#134 A.1–A.2)

Cloud's REST dispatch gated every mutating method on the coarse
AuthResult.permission summary, which collapses vault:write and vault:admin
into "full" — so a limited write token could rename/merge/delete/update tag
schemas over REST, the exact gap vault #580 closed on the self-hosted door.

- auth.ts: isTagSchemaMutation — the EXPLICIT admin-only REST enumeration
  (PUT/DELETE /tags/:name, POST /tags/merge, POST /tags/:name/rename),
  ported verbatim from parachute-vault routing.ts; permission field doc now
  warns it must never authorize admin.
- vault-do.ts: the REST scope gate now computes requiredVerb (admin for the
  enumerated ops, else verbForMethod) and checks hasScopeForVault against
  the real scope list — write-tier behavior byte-identical (permission
  "full" ⟺ hasScopeForVault(write)), admin ops 403 insufficient_scope with
  required_scope vault:admin (bun error-shape parity).
- mcp.ts: overrideVaultInfo's description-write bumped write → admin (bun
  mcp-tools.ts parity, message text identical); stale write-scope comments
  fixed. MCP tag tools were already admin via core's manifest + the
  visibleTools scope filter.
- tests: each of the four REST admin ops pinned refused-for-write (and
  not persisted) + allowed-for-admin; write tier pinned unbroken (notes
  CRUD, tag reads, conformance preview); MCP tools/list + tools/call
  write/admin split pinned; vault-info description refusal pinned for both
  read AND write tokens.

Out of scope (rest of ParachuteComputer#134): doctor route, aggregate, vault-info/vault map,
link-resolution parity, conformance-POST read carve-out.
… vault-info drift (cloud#134 review)

Adversarial review of 389381a (the A.1-A.2 write/admin scope split)
confirmed the chokepoint design but found two defects.

Defect 1 (fixed): POST /api/packs/:name dispatched at the generic write
tier, but handleApplyPack reaches core's applySeedPack -> upsertTagRecord
for every tag a pack declares - the exact mutation PUT /api/tags/:name
requires vault:admin for (isTagSchemaMutation). A write-tier token could
POST /api/packs/starter-ontology and silently overwrite an owner's
curated tag schema (upsertTagRecord replaces `fields` wholesale, no
merge). Adds isPackApply() (auth.ts) alongside isTagSchemaMutation() in
the vault-do.ts REST gate so pack-apply now requires vault:admin too.

The console's own "Add the Surface Starter guide" button mints its POST
/api/packs/:name call through workers/identity/src/console.ts's
postVaultApi, which minted a plain vault:<name>:write token - closing the
gate without touching this seam would have 403'd the console's only
legitimate caller. Re-minted at vault:admin instead; updated the pinned
mint-shape assertion in console.test.ts.

New regression coverage in conformance.test.ts: a write token is refused
(403 insufficient_scope / required_scope vault:admin) on POST
/api/packs/starter-ontology, with an operator read-back proving a
pre-existing curated tag schema (fields + description) survives
untouched; an admin token still succeeds. packs.test.ts's write-token
case is split into an admin-succeeds case and a write-refused case.

Defect 2 (annotated, not fixed): PATCH /api/vault still writes
`description` (and audio_retention/auto_transcribe) at the generic write
tier via REST (rest/vault.ts), while the MCP vault-info door was already
tightened to vault:admin for the same mutation (mcp.ts). The upstream bun
vault has the identical write-tier REST gap, so re-tiering cloud's REST
door alone would fork the wire contract. Added KNOWN GAP comments at both
sites: the MCP admin gate is advisory until both doors move together.

Refs cloud#134 (A.1/A.2) and its adversarial review.
…eview hardening)

The pack button's 60s admin token was stamped FIRST_PARTY_CLIENT_ID, which
is one half of internalForbidden's platform gate — admin verb made it both
halves, a latently platform-tier credential. New PACK_APPLY_CLIENT_ID
("parachute-packs") satisfies the packs scope gate (client_id never enters
that check) while internalForbidden refuses it on /api/internal/*, pinned
in workers/vault/test/internal-config.test.ts. postVaultApi renamed
postVaultPackApply: it hardcodes admin+this id for its one caller, and the
generic name was a trap for the next console write call-site. Stale
write-verb module doc corrected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@unforcedagi
unforcedagi force-pushed the fix/write-admin-scope-split branch from 7c365be to e395373 Compare August 11, 2026 19:30
@unforced
unforced merged commit 44cdd9c into ParachuteComputer:main Aug 11, 2026
3 checks passed
unforcedagi added a commit to unforcedagi/parachute-cloud that referenced this pull request Aug 11, 2026
…loud#186)

A failed plan-entitlement push (vault-call.ts mint seam) at create-time or
plan-change previously had no automatic recovery — a paying customer could
silently miss caps/voice forever, short of an operator running
scripts/backfill-plans.ts. Closes both fix directions from the issue:

- pushVaultCap now retries up to CAP_PUSH_MAX_ATTEMPTS (3) with a short fixed
  backoff before giving up, absorbing the common transient window cheaply
  (event=plan_cap_push_retry, then plan_cap_push_failed only after all
  attempts are exhausted).
- The existing daily USAGE_CRON rollup (usage.ts) now also reconciles: each
  vault's internal-config GET already carries the DO's resolved caps/frozen/
  transcription entitlement (vault-do.ts `shape()` already returned these
  fields), so the rollup compares that against the owner's CURRENT plan
  (mirroring applyPlanToVaults's resolution, not a separate source of
  truth) and re-pushes on any mismatch, logging event=entitlement_reconciled.
  This is bounded by construction: it only ever pushes exactly what
  entitlementPlanFor(plan, pending_plan) resolves to, so it can shrink an
  over-entitled DO (e.g. a stale push surviving a downgrade) as readily as it
  repairs an under-entitled one — never a grant beyond the current paid plan.

Tests (workers/identity/test/usage.test.ts, plans.test.ts):
- RETRY: a transient cap-push failure heals inside one pushVaultCap call.
- reconcile after a failed push restores the entitlement, then a second tick
  is a clean no-op (idempotent, single reconciled=1 then reconciled=0).
- reconcile is bounded by the current plan: a DO holding a richer stale
  entitlement (power) than the owner's actual plan (entry) is pulled DOWN to
  exactly entry's caps/voice, never left over-provisioned.

Overlap note for the eventual rebase onto main: PR ParachuteComputer#235 (fix/write-admin-
scope-split) also touched workers/identity/src/vault-call.ts, inserting
PACK_APPLY_CLIENT_ID near FIRST_PARTY_CLIENT_ID and a doc comment on the
`verb` field. This branch's edits are further down (CAP_PUSH_* constants
after INTERNAL_MINT_TTL_SECONDS, readVaultUsage, CapPushResult, pushVaultCap)
and touch none of the same lines/symbols — expected to merge cleanly.
unforced pushed a commit that referenced this pull request Aug 11, 2026
…loud#186) (#239)

* fix(identity): entitlement push retry + daily self-heal reconciler (cloud#186)

A failed plan-entitlement push (vault-call.ts mint seam) at create-time or
plan-change previously had no automatic recovery — a paying customer could
silently miss caps/voice forever, short of an operator running
scripts/backfill-plans.ts. Closes both fix directions from the issue:

- pushVaultCap now retries up to CAP_PUSH_MAX_ATTEMPTS (3) with a short fixed
  backoff before giving up, absorbing the common transient window cheaply
  (event=plan_cap_push_retry, then plan_cap_push_failed only after all
  attempts are exhausted).
- The existing daily USAGE_CRON rollup (usage.ts) now also reconciles: each
  vault's internal-config GET already carries the DO's resolved caps/frozen/
  transcription entitlement (vault-do.ts `shape()` already returned these
  fields), so the rollup compares that against the owner's CURRENT plan
  (mirroring applyPlanToVaults's resolution, not a separate source of
  truth) and re-pushes on any mismatch, logging event=entitlement_reconciled.
  This is bounded by construction: it only ever pushes exactly what
  entitlementPlanFor(plan, pending_plan) resolves to, so it can shrink an
  over-entitled DO (e.g. a stale push surviving a downgrade) as readily as it
  repairs an under-entitled one — never a grant beyond the current paid plan.

Tests (workers/identity/test/usage.test.ts, plans.test.ts):
- RETRY: a transient cap-push failure heals inside one pushVaultCap call.
- reconcile after a failed push restores the entitlement, then a second tick
  is a clean no-op (idempotent, single reconciled=1 then reconciled=0).
- reconcile is bounded by the current plan: a DO holding a richer stale
  entitlement (power) than the owner's actual plan (entry) is pulled DOWN to
  exactly entry's caps/voice, never left over-provisioned.

Overlap note for the eventual rebase onto main: PR #235 (fix/write-admin-
scope-split) also touched workers/identity/src/vault-call.ts, inserting
PACK_APPLY_CLIENT_ID near FIRST_PARTY_CLIENT_ID and a doc comment on the
`verb` field. This branch's edits are further down (CAP_PUSH_* constants
after INTERNAL_MINT_TTL_SECONDS, readVaultUsage, CapPushResult, pushVaultCap)
and touch none of the same lines/symbols — expected to merge cleanly.

* fix(identity): harden the entitlement reconciler against review findings D1-D4 (cloud#186)

Follow-up to 9efdb47, closing four correctness gaps found in review of the
retry + daily-reconciler work. Each is a case where the first pass could have
made a billing state WORSE rather than repairing it.

D1 — snapshot/plan race. `runUsageRollup`'s batch enumeration snapshots
u.plan/u.pending_plan once for the whole run, so a vault reached late in a long
run can be judged against a stale plan. A checkout completing mid-run would then
be clobbered straight back down — precisely the under-entitlement this issue
exists to prevent, only now on an automatic daily timer. Reconciliation now pays
for one fresh `getUserById` ONLY on a detected mismatch (rare at steady state,
so the "no extra D1 round-trip per vault" property still holds for the common
case) and pushes the FRESH entitlement, never the snapshot. A DO that already
reflects newer truth logs `entitlement_reconcile_skipped_race` and is left alone.

D2 — an unrecognized plan must not actuate. `coercePlanId` degrades an unknown
value to the 'expired' floor; combined with an actuating push that meant a
hand-edited row, a raw restore, or migration 0018's documented DEFAULT 'free'
would silently PUSH frozen:true onto a live, possibly-paying vault with no human
in the loop. The reconcile path now guards with `isPlanId` BEFORE computing any
entitlement and does nothing at all, logging
`entitlement_reconcile_skipped_unknown_plan`. An actuating path fails safe by
acting, never by freezing.

D3 — a vault-worker rollback must not zero out usage recording. Requiring the
resolved-entitlement fields inside `readVaultUsage` meant a rollback to a
pre-entitlement vault build would throw for EVERY vault, turning a
reconciliation-only outage into a fleet-wide loss of the usage rollup itself.
The entitlement is now an optional `VaultUsageReading.entitlement` (null =
fields absent entirely, distinct from the legitimate unpushed `caps: null`);
usage still records, and only that vault's reconciliation is skipped.

D4 — retry only what can succeed. The loop retried every non-2xx, so a
deterministic 400/403 burned the full budget plus backoff for a
guaranteed-repeat failure, delaying the operator-visible failure event.
`isRetryableStatus` limits retries to 5xx/429; transport errors (no status at
all) still always retry.

Tests: D1 race (fresh re-read, zero pushes, DO left on the newer plan), D2
unknown plan (zero pushes — the old code would have pushed a freeze), D3
rollback (usage still recorded, reconcile skipped, distinctly logged), D4
fail-fast 400 (1 attempt, never retried) and 5xx exhaustion (3 attempts, clean
result). The pre-existing push-failure test now `.persist()`s its interceptor:
single-shot, attempts 2-3 hit "mock dispatch not matched" — a TRANSPORT error,
not a second 500 — so it passed without ever exercising a real 5xx
retry-then-exhaust.

Known-deferred (D5, TODO in vault-call.ts): no per-attempt AbortSignal timeout
is threaded, so a HUNG (not fast-erroring) vault can burn the retry budget at
the platform's timeout rather than this module's.

* test(identity): end-to-end regression for the cloud#186 scenario

The two halves of the fix were tested separately — push-time retry (plans.test)
and the daily reconciler (usage.test, via a hand-placed mismatch). Neither
proved they COMPOSE, which is the actual claim the issue makes.

This drives the issue's headline narrative through the real seams: a paying
customer upgrades, `applyPlanToVaults` exhausts its full retry budget against a
503ing vault, and the assertion is made that the DO is genuinely left on the
trial caps WITHOUT the voice entitlement the customer is now billed for. The
window then closes, the daily rollup wakes, and the vault is repaired to exactly
standard's entitlement with no operator and no backfill-plans.ts run — then the
next tick pushes nothing at all.

Pins that the state a totally-failed push leaves behind is exactly the state the
reconciler detects, so a future change to either half can't silently decouple
them.

* chore: bump rc.131 → rc.132 (cloud#186 entitlement reconciler)

* fix(identity): pin the D1 fix, split reconcile error attribution, pin the cross-worker contract (cloud#186)

Four merge-gate items from fresh-eyes review.

1. TEST PINNING THE D1 CORE FIX. usage.ts pushes `freshExpected`, but NO test
   could tell: in every reconcile test the snapshot and the fresh re-read
   resolve to the same plan, and the D1 race test returns at the
   already-converged check before reaching the push. Flipping the push to
   `snapshotExpected` passed the entire suite. Added the case that
   distinguishes them — snapshot says standard, the DO holds trial (so a real
   repair is due), and the owner flips to a THIRD value (power) mid-run — and
   verified by mutation that it is the ONLY test that fails when the push
   carries the snapshot.

2. ERROR ATTRIBUTION. The reconcile call sat inside the try that owns the
   usage-recording accounting, but it makes calls the usage read never made (a
   D1 getUserById, a vault PUT). A throw there counted the vault as BOTH
   recorded and failed — breaking the recorded + failed <= vaults invariant the
   summary is read with — and logged event=usage_fetch_failed for something
   that never touched the usage fetch. Reconciliation now has its own
   try/catch logging event=entitlement_reconcile_failed; the usage row stands
   and reconciliation self-heals next tick. Pinned with a test that breaks only
   getUserById's query.

3. CROSS-WORKER PIN. The reconciler detects staleness by reading four field
   NAMES off the vault worker's GET /api/internal/config, and that check is
   deliberately non-fatal — a rename on the vault side would make identity read
   every vault as "pre-entitlement build" and SILENTLY skip reconciliation
   fleet-wide, at console.log level, with both suites green. Nothing on either
   side caught that. workers/vault/test/internal-config.test.ts now asserts all
   four fields in both the unpushed (caps:null, fields present) and
   post-push round-trip shapes; verified by mutation that renaming
   transcription_enabled fails it.

4. TODO CORRECTION. The D5 TODO proposed an AbortSignal as the fix for
   subrequest amplification, but a timeout caps wall-clock, not COUNT: change a
   PLAN_SPECS byte and every vault mismatches, so USAGE_CRON issues up to
   2 x USAGE_RUN_CAP = 1000 subrequests from fast, healthy responses with
   nothing hanging. Rewritten to state that correctly and to reference the
   filed follow-up cloud#238 (AbortSignal for hangs, RECONCILE_RUN_CAP for
   count, TOCTOU post-push re-read, parseResolvedCaps degrade).

---------

Co-authored-by: unforcedagi <unforcedagi@users.noreply.github.com>
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