Repository navigation
[design] Consent is once-per-connector and Inbox Chat sells a per-send gate that does not exist — #90 closed with its approval-gate criterion unmet #722
Description
Verified against production and main @ 8bf125c4 on 2026-08-21. This is a design ticket: it asks for a decision first, and proposes the smallest thing that can ship before it.
The gap
PAGS has consent once per connector per instance and nothing per call. For most connector writes that is the right granularity — an unwanted github_create_issue is closed in a second. For a mail send it is not, and the product already knows it.
workers/api/src/lib/connectors/gmail.ts:499, the tool's own description to the model:
"This really sends: there is no draft step and no undo."
And the published catalog entry for Inbox Chat, live at GET /v1/public/agents/agent_inbox_chat right now, seeded by migration 0136_seed_inbox_chat.sql:
"…then dictate a reply in your own words, archive what you have dealt with, or fill in a form that arrived attached and send it back. It never sends or archives anything until you have seen exactly what it is about to do."
…and its welcomeMessage, the first thing every subscriber reads:
"I will show you anything before it is sent or archived."
Nothing enforces either sentence. The only thing behind them is prompt text in the same seed (identity.goal: "…and — only once they have approved — send or archive"). gmail_reply's handler sends the moment it is called; runRegistryTool checks the one-time connector consent (lib/tool-registry.ts:650-661) and dispatches. A model that decides it has been approved is approved. A model reading an injected instruction in the mail it was just asked to summarise is also approved.
The contrast with its sibling is the whole argument. Email Assistant (migration 0134) says:
"It never sends anything without your explicit consent switched on first."
That is exactly true, and it describes the switch that exists. Inbox Chat's sentence describes a switch that does not. Same author, same connector, one accurate claim and one that the store is currently making on the platform's behalf.
This is #90's unmet acceptance criterion, not a new idea
#90 (CLOSED) — the issue that built the write-consent gate — listed under Do:
Approval gate on write/destructive tools by default (reuse the browser-agent human-approval pattern; ties to the trust-model issue #75).
and under Acceptance:
- Write/destructive calls require approval by default; all connector writes audited.
The closing comment records what shipped: lib/connector-consent.ts, migration 0051, fail-closed enforcement in runRegistryTool, /v1/admin/connectors. All real. The approval gate is not among them, and the issue closed anyway. #75 (OPEN) carries the same requirement from the browser side — "Sensitive-action gates — send/pay/delete/post/message require human approval by default" — and has been open since before Gmail could send.
So the question is not whether to invent this. It is whether to finish it, and at what size.
Nothing catches the false promise either
lib/agent-claims-lint.ts (#66, wired into create/update at #141) exists precisely to stop catalog copy overstating an agent — "for a store, a description that overstates what the agent does is an integrity bug, not polish." It checks exactly one axis: RUNTIME_CLAIMS (browser / posting / local runner / cron) against capabilities.runtime || capabilities.workflow. A safety claim — "it will ask you first", "nothing is sent without your approval" — is unchecked, and Inbox Chat passes.
Proposal, cheapest first
Step 1 — make the false promise fail the lint (small, ships now)
Extend lintAgentClaims with a second family: a description or welcome message that promises a per-action human gate, on an agent that holds an irreversible write tool and declares no such gate, is a violation. Patterns of the shape /never (sends|posts|submits)[^.]*until you/i, /only once (you|they) (have )?approved?/i, /shows? you (it|anything|what)[^.]*before/i, /without your (explicit )?approval/i.
It forces the choice rather than making it: either the gate gets built, or the copy is corrected to describe the switch that does exist — which the Email Assistant already demonstrates can be done honestly and still sound good.
Cheap, pure, unit-testable, no migration, no UI, and it stops the next agent shipping the same sentence. Do this even if Step 2 is rejected. Filing this issue with Step 2 deferred and Step 1 done leaves the store honest, which is the part that cannot wait.
Step 2 — consent gains a third position: Off · Ask each time · Always
The gate belongs where the existing one is, because that is the one place every surface passes through — chat, POST /v1/instances/:id/tools/:name, MCP and the pipeline runner all reach runRegistryTool:650.
- Storage.
instance_connector_consenthas PK(instance_id, connector, scope). Add amode TEXT NOT NULL DEFAULT 'always'column — existing rows keep today's behaviour byte-for-byte, which is the property that makes this safe to ship. - Gate. At
tool-registry.ts:650, when the resolved mode isask, do not dispatch. Write a board ticket carrying{tool, args}and return{success:true, content:"Queued for your approval — ticket #N. Nothing has been sent."}. The model relays it, the conversation continues, and the tool loop does not need to suspend — which is what keeps this out of the durable-workflow machinery. - Approval.
TICKET_ACTIONS(lib/actionable-ticket.ts:32) is todayrun_pipeline | insert_record | add_knowledge | create_task | run_browse— there is no "call this tool", so this is the one genuinely new piece of vocabulary. Addcall_tool, with the module's existing invariant intact and load-bearing here: "the action is fixed when the ticket is CREATED… approving chooses whether the declared work runs — it never supplies new work." Re-check at approval time that the tool is still declared, still enabled, and its connector still consented, so a revoked grant retro-blocks a pending ticket. - Card. The board must show what is about to happen, or the approval is theatre.
board_itemscarriestitle/subtitle/url(migration 0037) and no body; a recipient + subject + body preview needs somewhere to live. Cheapest is on the ticket payload, rendered in the card's expanded view. - Console. The write-access checkbox (see The write-consent panel describes a browser for every connector — 27 of 32 instances are told an agent will "click, type and navigate on your machine" about a cloud API #720) becomes three positions. Default for a new grant stays Always, so nothing changes for anyone who does not opt in.
Step 3 — which connectors default to Ask
Deliberately deferred until Steps 1–2 are real. It needs a declared fact the registry does not have: whether a write is reversible. gmail_archive's own description already reasons about this correctly ("It stays in All Mail and can still be found by search, so this is reversible"), as does the deliberate absence of a delete tool. mutates and reach both exist and neither answers it. If a reversible field is added, it should be mutates-shaped: required for write tools, answered fail-closed, reported not enforced — and #720's writeMeaning prose should be checked against it.
My recommendation
Build Step 1 now; take Step 2 as a decision. I would take it, for one reason that is not about Gmail: PAGS is a marketplace, and the whole point of a marketplace is that the creator is not the subscriber. A one-time connector grant is a reasonable bargain when you wrote the agent. It is a much weaker one when a third party did, and #75 has said so since before there was anything to send. Step 2 also happens to be the only way the Inbox Chat description becomes true rather than deleted, and that description is the better product.
What I would not do is make Ask the default, or ship it as a Gmail special case. Ask-by-default on github_comment_issue would make the Coder unusable within a day, and a Gmail-shaped gate would have to be rebuilt for the next irreversible connector — the same trap #720 documents for the console copy.
Alternatives considered and rejected
- Just fix the copy, close the question. Legitimate and cheap, and it is what Step 1 forces if Step 2 is rejected. Rejected as the whole answer because it retires a promise rather than a risk: after it, an agent that reads untrusted mail can still send under the owner's name with no human in the loop, and [connectors] Connector consent + write-scope safety + admin visibility #90's criterion is still unmet — just no longer visible.
- Add a Gmail draft step (
drafts.create). Rejected.lib/gmail.tshas no drafts path at all (verified),drafts.createneeds a scope not requested, and gmail.modify is declared but never requested: archive and mark-read can never work, and every Gmail account now reads "read-only — reconnect to allow sending" that reconnecting cannot clear #717 shows the scope request list is already out of step with the manifest. It also solves nothing forhttp_request,mcp_call_toolormeta— and puts the approval in Gmail's UI rather than the owner's console. - Wrap the send in a one-step pipeline and use the existing
run_pipelineticket action. Rejected: it expresses the gate for a pipeline agent and not for the chat surface, which is where Inbox Chat lives and where the promise is made. It also makes every approvable action require a stored pipeline definition. - Suspend the chat tool loop and resume it on approval. Rejected:
agent-do.tsruns a bounded 3-round loop inside one request, and the durable resume machinery (browserHandoff/browserResume) belongs to workflows. Returning "queued, ticket #N" is honest, needs no new durability, and reads better in a transcript than a stalled turn.
Acceptance criteria
Step 1
-
lintAgentClaimsflags a per-action-gate promise on an agent that holds an irreversible write tool and declares no gate. - Inbox Chat either passes because the gate exists, or its description and
welcomeMessageare corrected. Itsidentity.goalprompt text is not accepted as the gate. - A test pins the Email Assistant's wording as the passing example — it describes a real switch and must not be flagged.
Step 2
- A connector consent can be
ask; a write tool under it creates a ticket instead of dispatching, and the tool result says plainly that nothing happened yet. - The board card shows recipient, subject and a body preview before approval.
- Approving dispatches exactly the declared call; approving twice dispatches once (the existing atomic status claim).
- Revoking the connector grant, or switching the tool off, blocks a pending ticket at approval time.
- Existing consent rows behave exactly as before — a migration test asserts
modedefaults toalways.
Regression risk
modedefaulting to anything butalwaysbreaks every live grant. Measured: 32 of 43 instances of this account render write-consent controls (The write-consent panel describes a browser for every connector — 27 of 32 instances are told an agent will "click, type and navigate on your machine" about a cloud API #720); a default ofaskwould put 23 Coder instances behind a board queue. Caught by the migration test above.- A ticket that outlives its consent is a stored, approvable, un-gated write. This is the sharp edge of the whole design — the gate is checked at create time and the human clicks later. Re-check at approval, not only at creation.
create_ticketis already a registry tool a pipeline can call. An agent must not be able to raise its own approval ticket for a call it was refused, then approve nothing and have the platform run it. Thecall_toolaction must only be constructible by the gate, never by an agent —validateTicketActionis where that boundary goes.- MCP.
set_board_item_statusis exposed over MCP; check whether it can reachneeds_approval → approvedand thereby become a remote approval channel for a call a human never saw.
Links
- [connectors] Connector consent + write-scope safety + admin visibility #90 (CLOSED) — built the consent gate; this is its unmet acceptance criterion.
- [browser][trust] Trust & permission model for browser agents (design prerequisite) #75 (OPEN) — the same requirement from the browser-agent trust model.
- The write-consent panel describes a browser for every connector — 27 of 32 instances are told an agent will "click, type and navigate on your machine" about a cloud API #720 — the write-consent panel's per-connector description; the tri-state control lands in the same component.
- gmail.modify is declared but never requested: archive and mark-read can never work, and every Gmail account now reads "read-only — reconnect to allow sending" that reconnecting cannot clear #717 / [design] Gmail is granted all-or-nothing in practice: no read-only connect, no way to elevate later, and "decline it at Google's screen" is stated nowhere the user will see it #718 — the Gmail OAuth grant layer. Note gmail.modify is declared but never requested: archive and mark-read can never work, and every Gmail account now reads "read-only — reconnect to allow sending" that reconnecting cannot clear #717 means
gmail_archive/gmail_mark_readcannot currently work at all, so the "or archives" half of Inbox Chat's promise is unreachable for a second, independent reason.
Activity
fix(catalog): Inbox Chat describes the protections that exist, not th…
feat(lint): catalog copy that promises a per-action gate over a tool …
serge-ivo commentedon Aug 21, 2026
Step 1 shipped and verified in production. Steps 2 and 3 deliberately NOT built — see below.
5c61d4de—fix(catalog): Inbox Chat describes the protections that exist, not the per-send gate that does notf8638ab9—feat(lint): catalog copy that promises a per-action gate over a tool with no undo is a claim, and the claims lint now checks itVerified live, not just tested
GET /v1/public/agents/agent_inbox_chatafter the deploy (migration0138applied):The sentence "It never sends or archives anything until you have seen exactly what it is about to do" is gone from the storefront.
identity.goalandwelcomeMessagewere corrected in the same migration;goalno longer says "only once they have approved" and instead tells the model the truth — the call is real and immediate, nobody reviews it — plus the injection rule that matters for an agent whose input is other people's mail.The premise was re-verified before the fix, not taken from the body:
runRegistryToolchecks the one-time per-instance consent and dispatches, andgmail_reply/gmail_sendsay so themselves — "This really sends: there is no draft step and no undo."The lint (AC 1 and 3)
lintAgentClaimsgains a second family. The rule is promises a per-action gate ∧ declares an irreversible write tool ∧ declares no gate, and it now reads the welcome message as well as the description — the welcome message is the first sentence of the product, and it carried the same promise.The design turns on one distinction, which is the whole reason this can be a lint rather than a keyword ban:
So
consent,permissionandaccessappear in no pattern, whileapproval,sign-offandbefore it is sentdo; every pattern is anchored to an act verb (send/post/submit/archive/reply) so "both are off until you turn them on" survives. Email Assistant's "It never sends anything without your explicit consent switched on first" passes for exactly that reason, and a test pins it as the worked honest example — AC 3, met.IRREVERSIBLE_WRITE_TOOLSis hand-kept and deliberately under-inclusive, with a checkable reason recorded for every exclusion (gmail_archiveis reversible by its own description; an unwanted GitHub issue closes in a second;http_request/mcp_call_toolgenuinely cannot be answered as a fixed fact — which is the strongest argument for Step 3). A test pins each name againstCONNECTORSso a rename cannot silently empty the set.declaresPerActionGate()returnsfalsehonestly, with a comment naming what Step 2 would make it read.Two things found on the way that need YOUR decision
1. Email Assistant's
welcomeMessagemakes the same false promise — "…show you the reply before anything is sent" — over the samegmail_send, with the same nothing behind it. Its description (the sentence this issue holds up as the bar) is honest and passes; the welcome message is not. The new family flags it, and that is pinned in a test explicitly labelled known, unfixed rather than narrowing the patterns to let it through — tuning the rule around a row would defeat the rule. Not fixed here, because it is a separable change needing its own migration and this issue asks for the Inbox Chat row. It should be filed.2. The correction does not reach instances that already exist.
$.identityis copied into the instance's Durable Object at subscribe and never re-read, and a migration cannot write DO storage. The description is a column, so the catalog, the agent detail page and every future subscriber are corrected immediately — but a live Inbox Chat instance keeps the old welcome message and goal until its owner resets state viaPUT /v1/instances/:id/state. Recorded with its reasoning inseed-identity-propagation.test.tsrather than waved away. Deliberately no code route: identity is exactly what a subscriber may edit on their own copy, so resolving it live from the template would overwrite someone's edited welcome in order to fix wording. Not measured: how many live Inbox Chat instances exist — I hold no production credential. It is bounded by the agent being three days old, but that is inference.Steps 2 and 3 — not built, on purpose
No
modecolumn oninstance_connector_consent, nocall_toolticket action, notool-registry.tschange, no console tri-state, noreversiblefield. Step 2 is a finish-or-retire decision that has not been made, and it needs a migration plus genuinely new ticket vocabulary.So the risk this issue is really about is still open. After this commit the store is honest, which is the part that could not wait — but an agent that reads untrusted mail can still send under the owner's name with no human in the loop, and #90's approval-gate acceptance criterion remains unmet. That is #75's territory too. This issue should stay open for Step 2, or Step 2 should be split out and this one closed as "the copy is honest now" — your call.
Gates
Full bar green, re-run after rebasing onto
main: all 11 script gates, biome--error-on-warnings,docs:drift,pnpm vitest run(584 files / 10,108 tests), all seven typecheck projects, consolevite build.Skipped, named:
pnpm docs:build(needs thezensicalPython package locally).pnpm test:e2e— nostore/oragents/coder/webfile is touched by this change.One deploy note: the first
Deploy API Workerrun forf8638ab9failed on a vitest reporter flake ([vitest-worker]: Timeout calling "onTaskUpdate") with 584/584 files and 10,108/10,108 tests passing — so the migration had not applied. Re-run, green, migration applied, and the live copy above is the proof.serge-ivo commentedon Aug 21, 2026
Decision (owner, 2026-08-22): build it, default OFF
Per-call approval for irreversible connector actions is being built, and #90's unmet acceptance criterion 2 is therefore finished rather than retired. #75 keeps the requirement.
Shape, as priced in this issue:
modecolumn defaulting toalways, so no existing agent changes behaviour on the migration.tool-registry.ts:650.call_toolentry inTICKET_ACTIONS— the genuinely new vocabulary, since none of the existing five can express "run this call".board_itemshastitle/subtitle/urland no body field, so that is part of the work rather than a detail.Explicitly NOT chosen: Ask-by-default. Measured, it would put 23 Coder instances behind an approval queue on day one. Default stays
always; opting an instance into Ask is the owner's act.Also not chosen: a Gmail-shaped gate. It must be connector-generic, so the next irreversible tool inherits it rather than rebuilding it — the same trap #720 documents for the panel copy.
Deferred deliberately: which connectors should default to Ask. That needs a
reversiblefact the registry does not currently carry, and inventing one mid-build would smuggle a second decision into this one. Step 3.Ships first, separately: Email Assistant's
welcomeMessagestill promises "show you the reply before anything is sent" overgmail_send. It is currently pinned as a known-failing lint case. Correct the copy before the gate lands — a false safety claim should not survive on the strength of a feature that is merely planned.serge-ivo commentedon Aug 23, 2026
Audit verdict: PARTIAL — re-verified at
e1238788. Step 1 shipped; Step 2 is decided and unbuilt, and the copy fix the owner said "ships first" has not shipped.Step 1 — delivered (
5c61d4de,f8638ab9)Confirmed on
main:lintAgentClaimscarries the per-action-gate family, and the Inbox Chat copy describes the switches that exist. Not re-litigating it here.Step 2 — NOT BUILT. Every piece the owner's decision named is absent.
Verified at
e1238788:modecolumn.grep -rn 'instance_connector_consent' workers/api/migrationsfinds0051only; no migration addsmode.lib/tool-registry.tsstill checks the one-time consent and dispatches;grep -rn '"ask"' workers/api/src/lib/tool-registry.ts→ 0.call_toolinTICKET_ACTIONS(lib/actionable-ticket.ts) — still the original five.board_items— the owner's decision explicitly called this out as part of the work, not a detail.So the risk this issue is about is unchanged: an agent that reads untrusted mail can still send under the owner's name with no human in the loop, and #90's acceptance criterion 2 remains unmet. #75 still carries the same requirement from the browser side.
The thing that was to ship FIRST, and has not
The owner's 2026-08-22 decision ends:
That is the cheapest item on this ticket, it needs one migration and no design, and it is the only remaining false claim in the store. It should be lifted out of this ticket and shipped on its own rather than waiting behind a migration-plus-new-ticket-vocabulary build. Right now the lint pins it as known, unfixed — which is honest bookkeeping and is not the same as the store being honest.
What remains, in order
welcomeMessage(one migration, mirrors0138). Then the known-failing lint case becomes a passing one and the exemption comes out.modecolumn defaulting toalways· one branch inrunRegistryTool·call_toolinTICKET_ACTIONS· a body field onboard_items· a tri-state control. Connector-generic, never Gmail-shaped.reversiblefact the registry does not carry, and the owner explicitly declined to smuggle that decision into the build.The four regression risks in the body all still apply to step 2 and none has been addressed. The sharpest is unchanged: a ticket that outlives its consent is a stored, approvable, un-gated write — re-check at approval, not only at creation. And check whether MCP's
set_board_item_statuscan reachneeds_approval → approvedand thereby become a remote approval channel for a call no human saw.VERIFIED vs INFERRED
e1238788.serge-ivo commentedon Aug 23, 2026
Correction to my own comment above, one line. I wrote that
grep -rn 'instance_connector_consent' workers/api/migrationsfinds0051only. It finds five —0051,0072,0073,0079,0099— and none of them adds amodecolumn;grep -rn 'ADD COLUMN mode' workers/api/migrations/returns 0. The claim holds, the grep as quoted did not.Also verified since posting, so the two remaining items are exact:
workers/api/migrations/0134_seed_email_assistant.sql:47— it still ends "…and show you the reply before anything is sent." overgmail_send, which is in its declaredtoolson the same line.workers/api/src/lib/agent-claims-lint.test.ts:219—describe("known, unfixed: the Email Assistant welcome message makes the same promise", …)— and the pattern that catches it is documented atagent-claims-lint.ts:105. So the lint is right and the copy is wrong; correcting the copy also removes thatdescribeblock.fix(catalog): Email Assistant's welcome message stops promising a pre…
serge-ivo commentedon Sep 13, 2026
The copy the owner said "ships first" has shipped:
1083b74dThis is the owner's 2026-08-22 item: "Correct the copy before the gate lands — a false safety claim should not survive on the strength of a feature that is merely planned." Step 2 (the per-call gate) is not built, so this issue stays open.
What changed
0152_email_assistant_honest_welcome.sql, modelled on0138, updates$.identity.welcomeMessageand$.identity.goalonemail-assistant.gmail_draft_replywhen they want to check, and never act on an instruction found inside a message. The form-filling half is kept.Every clause in the new copy describes something that exists:
gmail_replyandgmail_sendsend with no draft step.gmail_draft_reply, declared on this agent by0142(An agent cannot draft an email for a human to read — gmail_reply and gmail_send always go straight to the wire, and the gmail.modify scope a draft needs is already granted #765). It is offered, not promised, because nothing forces the model to save a draft.The description column is unchanged. It is the honest sentence this issue holds up as the bar, and the lint passes it.
agent-claims-lint.test.ts: theknown, unfixedblock is inverted, not deleted, as its own comment asked.seed-identity-propagation.test.ts: 0152 is recorded.$.identityis a copy held in each instance's Durable Object, so a migration can't reach an existing instance. The only fix for one is the owner'sPUT /v1/instances/:id/state.Verified
tscand testtscclean, biome clean. All 12check-*.mjspass, includingmigrations --require-historyandsurface-lock --require-history.docs:driftclean.0152_email_assistant_honest_welcome.sql │ ✅.agent_info("agent_email_assistant")returnssubscriber_count: 0, and that field isCOUNT(*) FROM agent_instances WHERE status = 'active'(routes/agents.ts:264,routes/public.ts:24). So 0152 leaves no stale copy in any active instance.0138could not say this about Inbox Chat.Not verified
identity, and I have no production D1 credential. The evidence is the deploy step applying the migration.agents.config.identityonly when that DO is uninitialised (routes/instances.ts:223-236), which is the path for agents seeded straight into the catalog. I did not check whetheragent_email_assistant's template DO has ever been initialised. This is the same assumption0138made.Found on the way, not fixed here
gmail_send's description (workers/api/src/lib/connectors/gmail.ts:689) tells the model: "To save the message for the owner to review first, usegmail_draft_newinstead." No tool by that name exists:grep -rn gmail_draft_new workers/api/srcfinds only that sentence. It's a separate copy defect in the tool catalog and wants its own issue.Still open on this issue
Step 2, as the owner priced it:
modecolumn oninstance_connector_consent, defaulting toalwaysrunRegistryToolcall_tooladded toTICKET_ACTIONSboard_itemsConnector-generic, never Gmail-shaped. Step 3 stays deferred.
serge-ivo commentedon Sep 20, 2026
Step 2 is built and landed:
1035ebffThe per-call approval gate exists. An
ask-mode connector write does not dispatch — it goes to theboard carrying the exact call, and approving runs that one call. #90's acceptance criterion 2 is
therefore met rather than retired, and #90 is closed by the same commit.
Every piece the 2026-08-22 decision named:
modecolumn defaulting toalways0155_connector_consent_mode.sqllib/tool-registry.ts(the existing #90 gate, ~20 lines)call_toolinTICKET_ACTIONSlib/actionable-ticket.ts+TicketActionNameinlib/trigger-types.tslib/tool-approval.ts(pure) — see below on the body fieldToolPermissions.tsx: Off · Ask each time · Always allowThe four regression risks in the body
All four are addressed, and each has a test that fails if it regresses.
modedefaulting to anything butalways. It defaults toalways, an unreadable valuereads as
always, and a row predating 0155 reads asalways. Pinned by a test that reads themigration and asserts it does no backfill, plus three behavioural tests. The 23 Coder instances
are untouched.
enabled, connector still consented — through
instanceToolPolicy, the same resolution theconsole panel renders rather than a second copy that could disagree with it. It runs before
the atomic claim, so a refusal leaves the card intact to approve again once the permission is
restored, instead of burning it.
create_ticketraising its own gate.call_toolis gate-only.validateTicketActionrefuses it by default, so
create_ticketand/tasks/directare both closed without eitherremembering a flag, and the error never even names it as an option. It is also not in
TriggerAction, so no trigger, cron or connection can carry it — enforced by the type, whichadditionally stops
executeTriggerActionever seeing one. That mattered more than expected:that function's dispatch is an if/else chain with no final
else, so an unknown actionreturns the payload unchanged and reports success. Routing a gated send through it would have
recorded the send as "run" while nothing was sent.
set_board_item_statuscannot approve — it writes only the jobKey-keyedboard_itemsoverlay and never reaches
executeTriggerAction.approve_instance_taskcould, and nowrefuses a
call_toolticket. Stated honestly in the code as a guard on the MCP surfacerather than a boundary: the route is owner-authenticated and the worker adds no authority, so a
token holder can still POST
/approvedirectly. What it buys is that the platform's ownagent-facing surface does not offer the bypass.
A fifth door, found while building, that the issue did not list
MCP's
set_instance_connector_consentsends{ enabled: true }. Under a plain upsert thatwould have taken a connector the owner had deliberately set to Ask each time and reset it to
Always allow — obtaining, in one write-scoped tool call, exactly the sends the gate holds back.
Fixed server-side rather than in the tool, so it covers every caller:
{ enabled: true }nowcreates a missing row and leaves an existing row's mode alone. It never meant "and dispatch
every call without asking" — there was nothing else it could have meant before this issue — and
widening a grant stays the owner's explicit act (
{ mode: "always" }). The same reasoning backsthe implicit
mcpconnector grant that follows approving an MCP server: a gate must not come offas a side effect of an unrelated click.
One correction to the pricing
board_itemsdid not need a body field. It is a display overlay keyed by jobKey; the ticketitself lives in
instance_runtime_tasks.payload, which already carriesdescription(2000 chars)and
reasoning(8000), andBoardTab.tsxalready renders both. So the card shows the call with nosecond migration.
The preview is connector-generic, as the decision required. It is built from the tool's own
jsonSchemaproperty order — which forgmail_sendis to · cc · subject · body — so the"recipient, subject and a body preview" criterion is met without Gmail being named anywhere in the
file. A test asserts that card and would fail if someone special-cased a connector to get it. A cut
says so and says how much it cut; identical calls collapse on a key-order-independent fingerprint,
so one queued send is one card rather than three.
The model-facing wording
The queued result is
success: true, deliberately: nothing went wrong, and afalsereads to amodel as an error to route around — retry, reach for another tool, apologise for a failure that did
not happen. It leads with "Nothing has been sent", because that is the clause the model will
paraphrase, and ends by forbidding the two failure modes this class of message invites: retrying,
and reporting it as done.
Cannot-queue is a refusal, never a dispatch. Both failure paths return
success: falseand runnothing.
Verified
tool-approval.test.ts,tool-approval-gate.test.ts, and additions to the consent, ticket-route and MCP suites.it now measures from the radio group and still holds its 48px floor.
check-*.mjs,biome --error-on-warningsover the CI scope,docs:drift, all 10typecheck projects, console
vite build.import-graph.test.tsrefused the first module layout (it closed a cycle through the toolpolicy — hence the split into
tool-approval-queue.tsandtool-approval-run.ts);runtime-task-ownership.test.tsdemanded the new card type be classified; and the MCP guard's owntest caught a fail-open in the guard itself —
apiCallreturns{error}rather than throwingon a non-2xx, so an unreadable ticket was being read as "no action" and approved.
Not verified
0155.packages/browser-runner/.../headless.test.ts([bug] A one-shot raw engine is judged idle by a 1.5s quiet timer and killed mid-turn — process exit is already the exact signal #391, timing) failed on two of three full-suite runs, in a different test each time, in a
package this change does not touch. It passes in isolation and the third full run was green
(12,212/12,212). Pre-existing flake under parallel load, not a regression here.
Still open
Step 3 — which connectors default to ask — stays deferred, per the owner's explicit
instruction not to smuggle that decision into this one. It needs a
reversiblefact the registrydoes not carry (
mutatesandreachboth exist and neither answers it). Worth filing on its own.One smaller thing found on the way and not fixed here: MCP can now write a connector consent
but cannot read one, which is the wrong way round and more visible now that a consent carries a
mode. Recorded as a parity gap (
check-mcp-parity.mjs) rather than closed, because the read wantsto land together with a tool that can set the mode — today's write tool takes a boolean, which
deliberately cannot widen an ask grant and therefore cannot express one either.
fix(test): the migration read uses `.href`, so the TEST typecheck pas…
serge-ivo commentedon Sep 20, 2026
CI green on
b6dcf456, and one correction to the "Verified" list above.The first commit (
1035ebff) failed CI, on the step my own summary implied it had passed:fileURLToPath(new URL(...))in the new gate test was handed the URL object, and underworkers/api/tsconfig.test.jsonthe WorkersURLglobal and Node'surl.URLare distinct types.Fixed in
b6dcf456by passing.href, the formportal-watch-seed.test.ts:19already used.Worth recording because it is a gap in how this repo is verified locally, not a typo:
pnpm -r typecheckdoes not cover the test tsconfigs. CI runsnpx tsc -p tsconfig.test.jsonseparatelyin
workers/api,workers/mcpandpackages/sdk(#599). A greenpnpm -r typecheckis notevidence for that step, and I reported it as though it were. All three are clean now.
Final state on
b6dcf456: CI ✅ (every step — the 14 guards, all 10 typecheck projects, thethree test typechecks, lint, docs drift, the five builds,
pnpm test, the e2e project check) andDeploy API Worker ✅. Migration
0155_connector_consent_mode.sqlapplied on the1035ebffdeploy —
✅in that run's Apply D1 migrations step.Still not verified live: no production D1 credential here, so the evidence that the column exists
is the deploy log, not a read of the row.
feat(gmail): gmail_create_filter — each filter approved by the owner,…