Skip to content

feat(approvals): single-use grants bound to the parked tool call - #745

Merged
blove merged 4 commits into
mainfrom
blove/approval-capability-grants
Sep 28, 2026
Merged

blove merged 4 commits into
mainfrom
blove/approval-capability-grants

Conversation

@blove

@blove blove commented Sep 19, 2026

Copy link
Copy Markdown
Contributor

Implements #736, following the accepted design in #738 and the decision comment on open question 1.

Off by default (approvals.grants: "off"). Nothing changes for an existing app unless it opts in.

What this adds

A grant: 32 CSPRNG bytes minted when B4.run parks a human-in-the-loop approval, bound to that one parked call, required back when the approval is answered, and consumable exactly once.

Package Adds
@b4run/sdk InterruptGrantStore / InterruptGrantRecord / InterruptGrantConsumption, ApprovalGrantMinter, ApprovalGrantMode, createApprovalGrant, hashApprovalGrant, timingSafeHexEqual, createMemoryInterruptGrantStore (WebCrypto only — the main entry is loaded by the edge targets)
@b4run/core configureApprovalGrants / approvalGrantMode / mintGrantForPark / MissingApprovalGrantMinterError; approvals on B4Config; the park site mints
@b4run/langchain approvalGrantMinter → config.configurable[APPROVAL_GRANT_MINTER_KEY]
@b4run/cli minter construction, resume verification + consumption, void-on-supersede, resolveInterruptGrantStore
@b4run/ag-ui grant on the interrupt and on B4ResumeRequest; B4AguiInterrupt
@b4run/postgres-storage createPostgresInterruptGrantStore + INTERRUPT_GRANTS_MIGRATIONS
@b4run/sqlite-storage createInterruptGrantStore (the b4 dev default)

Storage uses the existing versioned runMigrations under pg_advisory_xact_lock, with its own migration component so it versions independently. No column default anywhere, every INSERT names every column, and the frozen-migration rule is written on the migration where the next person to change the shape will read it. Pinned by test/interrupt-grants-ddl.test.ts.

How the minter is injected, and how it fails closed

Two facts have to reach emitPermissionInterrupt, and they arrive by deliberately different routes:

  • The minter is per-run, closed over the thread whose resume endpoint will answer, so it can only come from the caller. It rides in config.configurable, following the threadId precedent at capabilities/types.ts, and the park site reads it with getConfig(). Nothing in core's call graph grows a storage parameter.
  • The mode is a deployment fact, and it is what makes the minter's absence detectable. It deliberately does not travel the same way. configurable injection is optional by construction — legacy invokers omit it exactly as they omit threadId — so a mode carried in configurable would go missing from precisely the invokers whose missing minter it is meant to catch, and "no mode and no minter" would be indistinguishable from "grants are off". Instead it is a process-wide latch set from the resolved config by prepareRouteExecution and at runtime boot, and it ratchets up only: a second app root in one process cannot weaken a stricter mode already in force.

The fail-closed rule: mode "required" + no minter ⇒ MissingApprovalGrantMinterError, thrown before interrupt(), so the turn aborts and nothing is parked. Asserted loudly in packages/core/test/capabilities/approval-grants.test.ts, which drives a real compiled graph and then checks the checkpoint holds no pending interrupt — a refusal that still parked would be the bypass wearing a stack trace. The test carries a "do not relax this" note.

Resume ordering

thread-access gate → tryClaim → readPendingInterrupts → resolvePendingResume → verify grants → consume → deliver, on both POST /threads/:id/resume and the AG-UI resuming branch (one shared helper, so the two cannot drift). The gate-before-tryClaim ordering documented at runtime-fetch-core.ts is preserved unchanged and grant checks inherit it wholesale.

403 grant_invalid is the same status, code and message for wrong grant / no row / malformed / other thread, so the endpoint is not an oracle. 409 grant_consumed echoes consumedAt + consumedDecision and does not re-execute. Superseded checkpoints void outstanding grants when a turn settles, giving 409 stale_interrupt.

Decisions taken — please object to any of these

Open questions 2–7 in §8 are not settled. Where I had to choose, I took the design's stated recommendation and the narrowest version.

  1. Q2 — deny/cancelled consumes the grant: YES (the design's recommendation). A denial is a decision, and a re-answerable denial is a replay surface of its own. Tested end to end.
  2. Q3 — subagent grant ownership: the thread whose resume endpoint answers the interrupt. In practice the parent: a subagent materializes as a child graph inside the parent's run, so interrupt() in a subagent node reads the parent's ambient config and therefore the parent's minter. Stated explicitly on createApprovalGrantMinter so a future out-of-process subagent cannot silently mint against a thread with no resume endpoint.
  3. Q4 — always is grantable, with no extra gate. Narrowest version. The design notes always writes a durable permission rather than making one decision and may warrant something stronger; I did not add it. Most likely of these to deserve a "no".
  4. Q5 — resumeKey still crosses the wire. Unchanged. Removing it is a breaking change to AttachInterrupt and the design calls it "a strict improvement" without settling it.
  5. Q6 — no default TTL (grantTtlMs omitted ⇒ no expiry; a human approval may sit overnight) and no deprecation schedule encoded. The default is "off", not the design's "optional": defaulting to "optional" would change behavior for every existing app in a minor.
  6. Q7 — interruptId's Math.random is unchanged. Out of scope here; it is not a credential under this design.
  7. "off" means "mint nothing", not the design's §6 "mint but never require." A mode called "off" that still opens a store and writes a row per park is not off. Cost, stated in the code: "off" → "required" strands prompts parked while it was off (409 grant_unavailable); step through "optional", which is the migration window.
  8. Grant check at step 5, not the design's step 4 — after resolvePendingResume rather than before. Strictly less oracle surface, and a grant is never checked against a body whose shape has not been validated.
  9. Both SQL backends declare the contract structurally rather than importing it from @b4run/sdk, matching each package's own ThreadsStore precedent and keeping the lockfile untouched. Say the word and both become type-only imports.
  10. resolveInterruptGrantStore is optional on RuntimeBootFallbacks, like loadThreadAccess, because that interface is exported. Absence is not a silent ungating: the park site still fails closed and resume answers 409 grant_unavailable.

What did not survive contact with the code

  1. Two of the four binding columns cannot exist at mint time. Inside the interrupt() throw, config.configurable.checkpoint_id is undefined (the parked checkpoint is not written yet) and resume_key does not exist either — it is LangGraph's XXH3-128 of the checkpoint namespace, computed when the write is persisted, by a hasher LangGraph does not export. Verified empirically. So the row stores checkpoint_ns, the graph position the park site does have and the preimage of resumeKey, and the other two halves are enforced against the live checkpoint at resume (the interrupt must still be pending) plus voidOutstanding when the thread moves on. That is strictly fresher than a copy of the checkpoint id would have been.
  2. The grant is in the envelope, so its plaintext is at rest in writes. Design §1 argued for attaching it at projection time to avoid exactly this; the park site cannot, having no storage handle and no reach into the three disclosure paths, and attaching downstream is the CLI-minting shape Design proposal (no implementation): approval grants bound to the parked tool call #738's decision rejected. The hash-only store therefore protects the consumption ledger, not the checkpoint — anyone with read access to the checkpoint store can answer a still-parked prompt. Stated in the docs under its own heading, not buried.
  3. AG-UI's Interrupt cannot carry a top-level grant alone. It is z.infer over a closed "strip"-mode InterruptSchema with no grant key, so a client that re-validates silently drops it — quietly unanswerable under "required". The grant is therefore emitted both top-level (on a widened B4AguiInterrupt) and in metadata, which survives the round trip. Read grant, fall back to metadata.grant.
  4. The design assumed the mode could ride with the minter. It cannot, without destroying the fail-closed check — see above.
  5. The resume pass mints one orphan grant. LangGraph re-executes the node on resume, interruptId is regenerated (it always has been), and a second row is written that is never disclosed and never presented. It is swept by voidOutstanding when the turn settles. Accepted and documented at the park site rather than traded for a dependency on LangGraph's unexported scratchpad.
  6. @b4run/web's test suite does not catch an invalid Callout type — only the Next.js prerender does, at build. Cost me a build cycle; worth a cheap guard someday.

Test plan

From the worktree root, one clean sequential run:

  • pnpm build → Tasks: 26 successful, 26 total
  • pnpm typecheck → Tasks: 54 successful, 54 total
  • pnpm lint → Tasks: 29 successful, 29 total (warnings only, all pre-existing — the two in execute-route-core.ts reproduce on the file at HEAD)
  • pnpm test → Test Files 1 failed | 557 passed | 22 skipped (580), Tests 1 failed | 6718 passed | 253 skipped (6972)
  • node scripts/check-docs.mjs → Docs completeness check passed.

The one failure is @b4run/workspace test/source-bundle.test.ts > "enforces 64 MiB total before encoding or decoding content" — the known memory-pressure flake #742 also hit, in a package this PR does not touch. Alone: pnpm --filter @b4run/workspace test → Test Files 9 passed (9), Tests 195 passed (195).

Real-Postgres lane (Testcontainers postgres:16): B4_TEST_PGSTORAGE=1 pnpm --filter @b4run/postgres-storage test → Test Files 11 passed (11), Tests 117 passed (117), zero skips — including 12 concurrent consumes of one grant yielding exactly one "consumed" and 11 "already_consumed" carrying the winner's decision, and both voidOutstanding shapes.

New coverage: 9 core park-site cases (minting, fail-closed, the ratchet), 3 adapter-injection cases (including that a route param cannot shadow the minter), 12 resume-endpoint cases (replay refused and not re-executed, oracle-indistinguishability asserted by comparing two whole response bodies, the "optional" bypass rule, cancelled-consumes, expiry, voiding, disclosure and re-readability), 7 AG-UI projection cases, 12 SQLite and 15 Postgres store cases.

Formatted only the touched files with pnpm exec biome check --write --config-path packages/config-biome/biome.json <files>. Changeset included. Not released, not merged.

🤖 Generated with Claude Code

@vercel

vercel Bot commented Sep 19, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated
b4-run Ignored Ignored Preview Sep 28, 2026 7:02pm UTC

Request Review

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Automated approval: this PR received an intelligent (AI) code review. See the review comments on this PR.

blove added a commit that referenced this pull request Sep 19, 2026
The maintainer chose Model A: compatibility with existing CopilotKit/AG-UI
clients outweighs Model B's better mechanical fit. B's reasoning is preserved
in "alternatives considered" because it explains the shape of what replaces
it — A is implemented over B's machinery, parking the call and retaining the
parked state while never showing it to the client.

The crux is the retained record. Naive A identifies the result by a
client-supplied toolCallId (inbound.ts:36) on an array the client resupplies
in full every run. Instead the turn parks, the server keeps a durable record
of the outstanding call as bookkeeping the client never sees, and a returning
tool message is matched against that record before anything reaches the model.
Delivery is an internal resume, and toAgentInput (execute-route-core.ts:301)
discards the client's input entirely on a resume — so the resupplied history
never reconstructs state. That is the whole security argument, and it rests on
existing behavior.

Two findings changed conclusions rather than decorating them.

First, the forgery risk is latent, not live. A role:"tool" message never
reaches the model at all: it is dropped by agui-handler.ts:503, which collapses
the array to the newest USER message, and again by agent-adapter.ts:1136, which
filters to role === "user". So toolCallId has no consumer anywhere downstream.
Naive A would CREATE that hole rather than inherit it, and naive A also simply
does not work today — the result is silently discarded. The requirement is
therefore a constraint on construction, not a blocking dependency on separate
global work, and the earlier "blocking" label is withdrawn.

Second, the first revision was wrong to say createReactAgent has no
interruptBefore/interruptAfter. It has both, plus postModelHook, in langgraph
1.4.9; B4 just never passes them. The turn ends via a stub tool whose run calls
interrupt(), which is the shape permission-gate.ts:466 already proves and the
only mechanism that works against the code as it stands. postModelHook and
interruptBefore are recorded as viable alternatives with their costs.

Keying the record on (thread_id, tool_call_id) rather than interrupt_id makes
the replayed insert idempotent: LangGraph re-executes an interrupted tool node
from scratch, and the provider tool-call id is stable across that cycle while
interrupt_id is not — which is why #745 had to sweep an orphan grant row.

Against #745 as implemented, client tool calls should NOT mint grants: a grant
only works if disclosed and echoed, and under A the park is deliberately
invisible, so disclosing one would be B wearing a different name. Records the
cost — a park that never calls mintGrantForPark is invisible to the
approvals.grants: "required" latch.

Carried forward unchanged: the untrusted-definition threat model, client_
namespacing, the clientTool permission key that closes the gateToolOp
exact-match inheritance trap, the caps pinned to MAX_ZOD_DEPTH, and #740's 422
for routes that never opted in.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
blove and others added 4 commits September 28, 2026 11:58
B4.run parked a tool call for human approval and resumed it when an answer
arrived, but said nothing about how many times. The parked interrupt was
addressed by `interruptId` and `resumeKey`, and neither is a credential:
`interruptId` is a millisecond timestamp plus ~31 bits of `Math.random`,
disclosed in the persisted envelope, and `resumeKey` is LangGraph's
deterministic XXH3 of the checkpoint namespace. `ThreadAccessPolicy` gates
who may touch a thread, but disclosure control is not consumption control —
inside a session that legitimately held the thread, nothing stopped an
approval being replayed, or a stale approval from an earlier proposal being
applied to the current one. The only defence was emergent from LangGraph
advancing the checkpoint; nothing enforced or tested it.

A grant is 32 CSPRNG bytes minted at the park site in `@b4run/core`, stored
only as a SHA-256 hash in a B4-owned `interrupt_grants` table added by an
additive versioned migration under the existing `runMigrations` advisory
lock. It rides out on the channels that already carry the prompt and comes
back as an opaque `grant` on the resume entry. Consumption is a conditional
`UPDATE ... WHERE consumed_at IS NULL AND voided_at IS NULL`, so the row
count decides the winner: atomic, durable across a restart, correct across
replicas. A replay is refused rather than re-executed.

The minter is injected through LangGraph's `config.configurable` — the same
channel this repo already uses for live per-call identity — so nothing in
core's call graph grows a storage handle. That injection is optional by
construction, and the absence fails closed: under `approvals.grants:
"required"`, a park with no minter aborts the turn instead of parking a
prompt nobody can answer safely. The mode deliberately does NOT travel the
same way; a mode that went missing alongside the minter could never detect
the minter's absence.

Off by default. Refs #736, #738.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The endpoint test seeds grant rows against a stub graph and a canned
pending write, and the core park-site test drives a bare compiled graph,
so nothing showed that the minter the adapter injects into
config.configurable still reaches the permission gate when the tool runs
inside the agent loop, which #820 rebuilt on langchain createAgent.

A real agent() route now parks an approve-gated tool through aimock, the
grant is disclosed on pending_interrupts, one resume runs the tool once,
a replay is refused with stale_interrupt without running it again, and a
resume without the grant is refused with grant_required. Both fail when
the adapter stops injecting the minter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…roup

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@blove
blove force-pushed the blove/approval-capability-grants branch from 876d23b to 9488ba1 Compare September 28, 2026 19:02
@blove

blove commented Sep 28, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto main at a683816e, which now includes #740, the createAgent migration (#820) and the 0.13.0 release prep.

Conflicts, 13 files:

  • Runtime code. In agent-adapter.ts, agui-handler.ts, runtime-fetch-core.ts and execute-route-core.ts, every conflict was an import or option-list union. Main added responseFormat, SystemMessage, MiddlewareAfterHook, the bounded-body helpers and createThreadPermissionsStore. The grant logic itself applied unchanged.
  • Docs registration. Main reorganized the nav, so "Approval Grants" now sits after Permissions in the Secure section. Every page-count pin is main's value plus one (journey 64, all docs 80, inventory 93). The count bump was checked against this PR's own +1 diff.
  • lastmod. Regenerated for /docs/approval-grants and the reference pages this PR edits.
  • Interplay with feat(cli): validate the AG-UI run envelope and close client tools by default #740. On AG-UI the grant travels in the resume entries, not forwardedProps, so feat(cli): validate the AG-UI run envelope and close client tools by default #740's closed default doesn't affect it.

Added:

  • packages/cli/test/approval-grants-agent-route.test.ts. The endpoint test seeds grants against a stub graph, and the core test drives a bare graph. So nothing showed that the minter injected into config.configurable still reaches the permission gate inside the agent loop feat(langchain): run agent routes on createAgent; returnDirect ends only on success #820 rebuilt. The new test covers it:
    • A real agent() route parks an approve-gated tool through aimock.
    • pending_interrupts discloses the grant.
    • One resume runs the tool exactly once.
    • A replay gets 409 stale_interrupt and doesn't run it again. (The interrupt is no longer pending, so resolvePendingResume answers before the grant is read. grant_consumed applies only to a still-pending interrupt.)
    • A resume without the grant gets grant_required.
    • Both tests fail when the adapter stops injecting the minter.
  • The changeset now uses patch instead of minor, per AGENTS.md's rule for the 0.x fixed group.

Checked on the rebased code:

  • sdk 115, core 605, langchain 286, ag-ui 220, sqlite-storage 126, cli 2353, web 979.
  • B4_TEST_PGSTORAGE=1 postgres-storage: 117/117 on a real Postgres, including the 12-way concurrent consume.
  • Root lint and typecheck, check-docs, seo:lastmod:check.

The decisions listed in the description, Q4 (always is grantable) in particular, are still open for the owner.

@blove
blove merged commit 17f16ea into main Sep 28, 2026
29 of 30 checks passed
@blove
blove deleted the blove/approval-capability-grants branch September 28, 2026 20:54
blove added a commit that referenced this pull request Sep 30, 2026
The maintainer chose Model A: compatibility with existing CopilotKit/AG-UI
clients outweighs Model B's better mechanical fit. B's reasoning is preserved
in "alternatives considered" because it explains the shape of what replaces
it — A is implemented over B's machinery, parking the call and retaining the
parked state while never showing it to the client.

The crux is the retained record. Naive A identifies the result by a
client-supplied toolCallId (inbound.ts:36) on an array the client resupplies
in full every run. Instead the turn parks, the server keeps a durable record
of the outstanding call as bookkeeping the client never sees, and a returning
tool message is matched against that record before anything reaches the model.
Delivery is an internal resume, and toAgentInput (execute-route-core.ts:301)
discards the client's input entirely on a resume — so the resupplied history
never reconstructs state. That is the whole security argument, and it rests on
existing behavior.

Two findings changed conclusions rather than decorating them.

First, the forgery risk is latent, not live. A role:"tool" message never
reaches the model at all: it is dropped by agui-handler.ts:503, which collapses
the array to the newest USER message, and again by agent-adapter.ts:1136, which
filters to role === "user". So toolCallId has no consumer anywhere downstream.
Naive A would CREATE that hole rather than inherit it, and naive A also simply
does not work today — the result is silently discarded. The requirement is
therefore a constraint on construction, not a blocking dependency on separate
global work, and the earlier "blocking" label is withdrawn.

Second, the first revision was wrong to say createReactAgent has no
interruptBefore/interruptAfter. It has both, plus postModelHook, in langgraph
1.4.9; B4 just never passes them. The turn ends via a stub tool whose run calls
interrupt(), which is the shape permission-gate.ts:466 already proves and the
only mechanism that works against the code as it stands. postModelHook and
interruptBefore are recorded as viable alternatives with their costs.

Keying the record on (thread_id, tool_call_id) rather than interrupt_id makes
the replayed insert idempotent: LangGraph re-executes an interrupted tool node
from scratch, and the provider tool-call id is stable across that cycle while
interrupt_id is not — which is why #745 had to sweep an orphan grant row.

Against #745 as implemented, client tool calls should NOT mint grants: a grant
only works if disclosed and echoed, and under A the park is deliberately
invisible, so disclosing one would be B wearing a different name. Records the
cost — a park that never calls mintGrantForPark is invisible to the
approvals.grants: "required" latch.

Carried forward unchanged: the untrusted-definition threat model, client_
namespacing, the clientTool permission key that closes the gateToolOp
exact-match inheritance trap, the caps pinned to MAX_ZOD_DEPTH, and #740's 422
for routes that never opted in.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
blove added a commit that referenced this pull request Sep 30, 2026
…ecord (#744)

* docs(specs): settle client-provided tools on park-and-resume

B4.run accepts `RunAgentInput.tools` and never reads it. #740 made that
audible by rejecting a non-empty `tools` unless a route opts in, which left
`server.agui.clientTools` meaning "tolerate tools I will never call".

This settles the model rather than implementing it. The issue's central
question — how the tool result returns — has a code-grounded answer: Model A
("end the run at the tool call") has no native expression in this substrate.
`createReactAgent` is compiled with no `interruptBefore`/`interruptAfter`
(agent-adapter.ts:188) and `interrupt()` at permission-gate.ts:466 is the only
thing in the repo that halts a graph mid-turn. A would have to park and then
discard the very record requirement 6 needs, so it is B plus amnesia rather
than B minus complexity. The design is therefore B, with an adapter that
translates an A-shaped client's tool message into the equivalent resume.

It also records three findings that make this a design note and not a PR:
single-use protection (requirement 6) is #736, which exists only as proposal
#738; there is no capability surface at all to set `clientProvided` on
(requirement 5), so satisfying it means inventing a new public endpoint; and
the resume payload widening touches three symbols exported as public API from
@b4run/cli. A replayed client tool result injects attacker-chosen content, so
it must not ship on the emergent, untested replay protection that exists now.

The threat model is written down rather than implied: AG-UI's own ToolSchema
has `parameters: z.ZodAny` and an unbounded `description`, so the protocol
imposes no limits on caller-authored prompt content. Namespacing what the
model sees closes both name confusion and a live privilege-inheritance bug —
`gateToolOp` matches exact names under the exact-match `tool` permission key,
so a client tool named `readFile` would inherit an operator's existing
`tool:readFile` allow rule and run with no prompt.

Also corrects the docs, which did not say that opting in to `clientTools`
accepts the field without executing it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(specs): rewrite client-tools design around Model A

The maintainer chose Model A: compatibility with existing CopilotKit/AG-UI
clients outweighs Model B's better mechanical fit. B's reasoning is preserved
in "alternatives considered" because it explains the shape of what replaces
it — A is implemented over B's machinery, parking the call and retaining the
parked state while never showing it to the client.

The crux is the retained record. Naive A identifies the result by a
client-supplied toolCallId (inbound.ts:36) on an array the client resupplies
in full every run. Instead the turn parks, the server keeps a durable record
of the outstanding call as bookkeeping the client never sees, and a returning
tool message is matched against that record before anything reaches the model.
Delivery is an internal resume, and toAgentInput (execute-route-core.ts:301)
discards the client's input entirely on a resume — so the resupplied history
never reconstructs state. That is the whole security argument, and it rests on
existing behavior.

Two findings changed conclusions rather than decorating them.

First, the forgery risk is latent, not live. A role:"tool" message never
reaches the model at all: it is dropped by agui-handler.ts:503, which collapses
the array to the newest USER message, and again by agent-adapter.ts:1136, which
filters to role === "user". So toolCallId has no consumer anywhere downstream.
Naive A would CREATE that hole rather than inherit it, and naive A also simply
does not work today — the result is silently discarded. The requirement is
therefore a constraint on construction, not a blocking dependency on separate
global work, and the earlier "blocking" label is withdrawn.

Second, the first revision was wrong to say createReactAgent has no
interruptBefore/interruptAfter. It has both, plus postModelHook, in langgraph
1.4.9; B4 just never passes them. The turn ends via a stub tool whose run calls
interrupt(), which is the shape permission-gate.ts:466 already proves and the
only mechanism that works against the code as it stands. postModelHook and
interruptBefore are recorded as viable alternatives with their costs.

Keying the record on (thread_id, tool_call_id) rather than interrupt_id makes
the replayed insert idempotent: LangGraph re-executes an interrupted tool node
from scratch, and the provider tool-call id is stable across that cycle while
interrupt_id is not — which is why #745 had to sweep an orphan grant row.

Against #745 as implemented, client tool calls should NOT mint grants: a grant
only works if disclosed and echoed, and under A the park is deliberately
invisible, so disclosing one would be B wearing a different name. Records the
cost — a park that never calls mintGrantForPark is invisible to the
approvals.grants: "required" latch.

Carried forward unchanged: the untrusted-definition threat model, client_
namespacing, the clientTool permission key that closes the gateToolOp
exact-match inheritance trap, the caps pinned to MAX_ZOD_DEPTH, and #740's 422
for routes that never opted in.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(web): regenerate SEO lastmod for the client-tools docs corrections

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(specs): record the maintainer's answers to the client-tools open questions

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

This branch was successfully deployed

1 active (outdated) and 1 inactive deployments
vercel-preview — 9488ba11 Deployed Sep 28, 2026 by blove via vercel-native #2448
Preview — 876d23b9 Deployed Sep 19, 2026 by vercel[bot]
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