From 983c9c8ecadf0b0a247c8642e68658a680dd5c85 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 04:27:35 +0000 Subject: [PATCH] docs(runtime,qa): record the /automation manage_metadata write gate in the two ledgers that describe the domain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The #10145 authoring-write gate shipped and is pinned, but neither hand-maintained ledger that describes the /automation domain's authorization posture says so, and both exist precisely so the next author finds the decision they are standing on. - route-ledger.ts: the three write rows (POST /automation, PUT and DELETE /automation/:name) each carry a note in the resume row's own convention — the capability, the fail-closed construction, the 403 PERMISSION_DENIED refusal, the isSystem bypass, and the one isFlowAuthoringWrite predicate that selects them. - authz-conformance.matrix.ts: the anonymous-deny-automation row's enforcement prose gains the per-route capability clause in the /packages sibling's shape, so the domain's second gate stops being invisible to a reader of the ledger. Prose only. No behaviour changes, no ratchet moves, no covers key added — the discover() probe for the matrix file matches shouldDenyAnonymous( only, so this gate adds no discovered key. Part of #10244 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019bmVFqoQPq63zhKrxdYG1r --- packages/qa/dogfood/test/authz-conformance.matrix.ts | 2 +- packages/runtime/src/route-ledger.ts | 9 ++++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/qa/dogfood/test/authz-conformance.matrix.ts b/packages/qa/dogfood/test/authz-conformance.matrix.ts index 2dda0bfb7c..48360d6627 100644 --- a/packages/qa/dogfood/test/authz-conformance.matrix.ts +++ b/packages/qa/dogfood/test/authz-conformance.matrix.ts @@ -148,7 +148,7 @@ export const AUTHZ_CONFORMANCE: AuthzPrimitive[] = [ covers: ['actions:domains/actions.ts:anonymous-gate'], note: 'A `type: \'script\'` action body runs `isSystem: true` (elevated), so an ungated POST was an anonymous privilege-escalating WRITE, not merely an information leak — #5519 measured `POST /actions/showcase_task/showcase_mark_done/:id` answering 200 with the update applied. Internal dispatch is unaffected: this handler is a pure HTTP seam (the MCP `run_action` bridge enters through action-execution.invokeBusinessAction, declarative endpoints through the transport fallback seam with their own `authRequired` gate), so `authRequired: false` public endpoints stay public.' }, { id: 'anonymous-deny-automation', summary: 'anonymous-deny on the automation/flow surface (#2567 surface 3 / #5519)', state: 'enforced', - enforcement: 'runtime/domains/automation.ts handleAutomationRequest — shouldDenyAnonymous DOMAIN-WIDE at the top, and deliberately BEFORE the isServiceServeable probe so the 401/501 difference cannot be used to fingerprint whether a deployment mounts automation', + enforcement: 'runtime/domains/automation.ts handleAutomationRequest — shouldDenyAnonymous DOMAIN-WIDE at the top, and deliberately BEFORE the isServiceServeable probe so the 401/501 difference cannot be used to fingerprint whether a deployment mounts automation; per-route capability predicates run after this floor — `manage_metadata` for the three flow-AUTHORING writes (create `POST /` / update `PUT /:name` / deregister `DELETE /:name`, selected by the one `isFlowAuthoringWrite` predicate, #10145), fail-closed by construction (an absent executionContext, an absent `systemPermissions` or an empty one all refuse) and answering 403 `PERMISSION_DENIED`, with only engine `isSystem` bypassing; the run-state reads (#7900) and `resume` (#3801 / #5561) carry their own separate per-route predicates, and the execution doors (trigger / execute / toggle) sit outside all of them', proof: 'showcase-anonymous-deny-surfaces.dogfood.test.ts', covers: ['automation:domains/automation.ts:anonymous-gate'], note: 'Ungated, an anonymous caller could start real flow runs (`POST /:name/trigger`), read the full flow inventory (`GET /automation`), and DEREGISTER a registered flow (`DELETE /:name` → `{deleted:true}`) — the destructive one, which #5519 did not originally record. Gating the DOMAIN rather than each route is what keeps a newly added automation route from arriving ungated. Engine-internal triggers (record-change, schedule) never speak HTTP and are untouched.' }, diff --git a/packages/runtime/src/route-ledger.ts b/packages/runtime/src/route-ledger.ts index 5a4d13ce01..d0c8ad1f7a 100644 --- a/packages/runtime/src/route-ledger.ts +++ b/packages/runtime/src/route-ledger.ts @@ -316,7 +316,8 @@ export const ROUTE_LEDGER: readonly RouteLedgerEntry[] = [ { route: 'POST /automation/trigger/:name', domain: '/automation', disposition: 'sdk', client: 'automation.trigger', note: 'legacy verb-first shape; duplicates execute() against a different URL — candidates for consolidation' }, { route: 'GET /automation', domain: '/automation', disposition: 'sdk', client: 'automation.list' }, - { route: 'POST /automation', domain: '/automation', disposition: 'sdk', client: 'automation.create' }, + { route: 'POST /automation', domain: '/automation', disposition: 'sdk', client: 'automation.create', + note: "authored metadata, so `manage_metadata` gates it (#10145): a flow definition lives on the metadata plane (ADR-0106), and this door now asks the capability every other door onto that plane already asks. Fail-closed by construction — an absent executionContext, an absent `systemPermissions` or an empty one all fall through to the refusal, 403 with code `PERMISSION_DENIED` (ADR-0112); only engine self-invocation (`isSystem`, never settable from the wire) bypasses. WHICH routes is one predicate, `isFlowAuthoringWrite` in `domains/automation.ts` — this row plus PUT/DELETE `/:name` below, with the execution doors (trigger / execute / toggle / resume) deliberately outside it. Second layer, not the first: the #5519 anonymous floor answers an unidentified caller 401 here, not 403. Pinned in `domains/automation-write-capability-gate.test.ts`" }, { route: 'GET /automation/actions', domain: '/automation', disposition: 'sdk', client: 'automation.listActions' }, { route: 'GET /automation/connectors', domain: '/automation', disposition: 'sdk', client: 'automation.listConnectors' }, { route: 'GET /automation/_status', domain: '/automation', disposition: 'sdk', client: 'automation.getRuntimeStatus' }, @@ -328,8 +329,10 @@ export const ROUTE_LEDGER: readonly RouteLedgerEntry[] = [ { route: 'GET /automation/:name/runs/:runId', domain: '/automation', disposition: 'sdk', client: 'automation.getRun' }, { route: 'GET /automation/:name/runs', domain: '/automation', disposition: 'sdk', client: 'automation.listRuns' }, { route: 'GET /automation/:name', domain: '/automation', disposition: 'sdk', client: 'automation.get' }, - { route: 'PUT /automation/:name', domain: '/automation', disposition: 'sdk', client: 'automation.update' }, - { route: 'DELETE /automation/:name', domain: '/automation', disposition: 'sdk', client: 'automation.delete' }, + { route: 'PUT /automation/:name', domain: '/automation', disposition: 'sdk', client: 'automation.update', + note: "authored metadata, so `manage_metadata` gates it (#10145) — the same `isFlowAuthoringWrite` door as `POST /automation` above: fail-closed on an absent executionContext, an absent `systemPermissions` or an empty one, refusing 403 `PERMISSION_DENIED`, with only `isSystem` bypassing" }, + { route: 'DELETE /automation/:name', domain: '/automation', disposition: 'sdk', client: 'automation.delete', + note: "authored metadata, so `manage_metadata` gates it (#10145) — the same `isFlowAuthoringWrite` door as `POST /automation` above, and the destructive member of the family: before the gate a tenant org owner WITHOUT the capability deregistered a registered flow, 200, and flow metadata is registered at ENVIRONMENT scope so the write crossed the tenant wall. Fail-closed on an absent executionContext, an absent `systemPermissions` or an empty one, refusing 403 `PERMISSION_DENIED`, with only `isSystem` bypassing" }, // ── auth (better-auth passthrough) ──────────────────────────────────────── { route: '* /auth/**', domain: '/auth', disposition: 'sdk', client: 'auth.me',