Uh oh!
There was an error while loading. Please reload this page.
fix(runtime): POST /automation/:name/toggle joins the manage_metadata write set - #11660
Conversation
… write set Implements the maintainer ruling of 2026-08-23 on #10243 (option A): flow enablement is an authoring write, gated by one arm on the existing isFlowAuthoringWrite predicate. No new capability name. BREAKING: a caller holding a session but not `manage_metadata` is answered 403 PERMISSION_DENIED where it previously received 200 with the flow toggled. The execution doors (trigger / legacy trigger / resume) and every read are untouched. The measurement that settled it: the enabled bit is not a row, so no organization wall scopes it. toggleFlow writes an in-process map keyed by flow name only, getFlowRuntimeStates() reads it with no caller and no organization, and the automation service is one instance per environment — so an unentitled tenant org owner switched a shipped flow off and an unrelated tenant in a different organization, plus the platform admin, read it off. The predicate matches the router exactly rather than approximately: no upper bound on path depth (the router's toggle arm has none, so parts.length === 2 would have been a bypass), and POST /automation/trigger/:name is excluded so a flow literally named `toggle` keeps its execution door. Both named pins are flipped deliberately in this commit, plus three prose surfaces that could never go red (route-ledger, the authz conformance matrix, the handler's own route map) and two test files that would have gone red unannounced (automation-toggle-unknown-flow, http-dispatcher) where only the caller changes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019siH5jDmk5hrayvfyojUqR
📓 Docs Drift CheckThis PR changes 2 package(s): 2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 24 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin d60c19447e750bafc35887505cc91ba0948822dc && git checkout d60c19447e750bafc35887505cc91ba0948822dc
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin cad8b42f009a4a703ccec6c58bdf2995f48f6362 fa0aa1aaad7c3a65fae1de52e8adce65b08a2464 && git checkout -B drift-repro cad8b42f009a4a703ccec6c58bdf2995f48f6362 && git merge --no-ff fa0aa1aaad7c3a65fae1de52e8adce65b08a2464
node scripts/docs-audit/affected-docs.mjs --json cad8b42f009a4a703ccec6c58bdf2995f48f6362
|
os-zhuang
commented
Aug 24, 2026
ACCEPT — PM seat review ( |
Uh oh!
There was an error while loading. Please reload this page.
Fixes#10243
Implements the maintainer ruling of 2026-08-23 (comment
5386676024), quoted unmodified:Ruled: A —
POST /api/v1/automation/:name/togglejoins themanage_metadatawrite set. One arm on the existingisFlowAuthoringWritepredicate; ⛔ no new capability name (option C was declined on the startup-scope axis). Nothing here re-opens the decision.The breaking surface, stated plainly
manage_metadataPERMISSION_DENIED,toggleFlownever enteredisSystem)The changeset is minor with this table spelled out. The execution doors did not move:
POST /:name/trigger, the legacyPOST /trigger/:nameandPOST /:name/runs/:runId/resumekeep their posture, andGET /_statusstill serves enablement to any authenticated caller — this narrows mutating the bit, not observing it.The fix is one arm, and it is matched to the router rather than approximated
Two boundary decisions, both deliberate, both pinned by their own test:
parts[1] === 'toggle'with no length check, soPOST /automation/:name/toggle/anythingstill reachestoggleFlow. A gate writtenparts.length === 2would have been narrower than its own route — a bypass, not a style difference.parts[0] === 'trigger'is excluded.POST /automation/trigger/:nameis the legacy execution door and the router answers it above the toggle arm, so for a flow literally namedtogglethe path/automation/trigger/toggleRUNS that flow. Gating it would over-block an execution door — the one thing the ruling did not do, and the mistake finding:GET /automation/:name/runs/:runId/screenstill discloses record-derived values to any authenticated caller who knows a run id #7968 records for the paused-run screen read.Five surfaces carried the old policy. Seven moved.
The ruling named two pins. The claim comment added two prose surfaces. Implementing it turned up two more that would have gone red — not false prose, actually failing tests:
packages/runtime/src/domains/automation.ts.../automation-write-capability-gate.test.tstogglejoinsAUTHORING_WRITESso it inherits the whole batterypackages/qa/dogfood/test/automation-toggle-tenant-scope.dogfood.test.tspackages/runtime/src/route-ledger.tstoggleremoved from the "execution doors outside it" list; the bare toggle row gains a note in its siblings' voicepackages/qa/dogfood/test/authz-conformance.matrix.tstoggleremoved from the execution-door list.../automation-toggle-unknown-flow.test.tsCTXwas{ userId: 'user_1' }with no capability, so every #7535 error-mapping case would have stopped at the 403 in front of the behaviour it is named afterpackages/runtime/src/http-dispatcher.test.tsAUTHED_CALLER(); switched to the file's existingFLOW_AUTHOR(), and theFLOW_AUTHORdocblock's "every EXECUTION route (trigger / toggle / resume) keepsAUTHED_CALLER, deliberately" is now false and was correctedFor 6 and 7 only the caller changes — every mechanism, assertion and expected value is untouched, and each carries a comment saying so, matching how #7019 / #7033 / #10145 handled the same situation in the same files.
Also in surface 1: the handler's route map annotated
⚑capability markers on the three #10145 authoring writes, which had none. They were not false, but a map that marks the toggle gate while leaving its three siblings unmarked reads as "those are ungated". Comment-only, declared here rather than slipped in.What the dogfood pin measures now
⛔ Not deleted. Re-pointed, and which legs changed is the substance:
enabled: true. Asserting the spy-equivalent (state unmoved) matters: "toggle first, refuse second" would satisfy a status-only assertion and still be the defect.it('mutates ENGINE state, not the persisted definition'). The gate changed who may toggle, not where the bit lives. That leg is what made the original result independent of the harness's missing row wall (multiTenant: 'posture-only'), and it is still the leg that would fail loudly if enablement ever became org-stamped state a wall could scope. Only its driver changed — an entitled caller, since an unentitled one no longer gets that far.toggle: a control has to be independent of the route under test.Consumer inventory — measured, and it comes back zero
Axis ② of the accepted analysis flagged this as the one place option A could misstep:「「今天谁在调 toggle」没有被测量」. Measured now.
This repo —
git grep -n "automation\.toggle\|toggleFlow(\|/toggle", all packages:packages/client/src/index.ts:3202— the SDK methodautomation.toggleitself. A library binding, not a UI caller; its test mocksfetch.docs/qa/platform-checklist/areas/automation.json— a manual QA procedure that toggles a showcase flow. Runs as the seeded platform admin.oauth2/toggle-disabled(unrelated), UI filter vocabulary (dropdown/toggle), or CHANGELOG prose.packages/console,packages/mcp,packages/rest,packages/cli,examples/*.The Console UI —
packages/consoleships no tracked source (4 files:.gitignore,CHANGELOG.md,README.md,package.json), so the question is not answerable from this repo. Measured in the sibling repo instead —objectstack-ai/objectuiate52fac0:git grep "automation/[^\"']*toggle|automation.toggle|toggleFlow"` ⇒ zero hits.automation, and its source POSTs to automation routes in three places —useConsoleActionRuntime.tsx:546andRecordDetailView.tsx:802(/trigger),FlowRunner.tsx:159(/runs/:runId/resume). Both are execution doors and both stay ungated.FlowStatusDot/StudioDesignSurface.tsx:3227display{ enabled, bound }read fromGET /_status. Display only — no enable/disable control exists to lock anyone out of.⇒ No console surface exposes flow enable/disable to ordinary members, so the over-block risk axis ② named does not materialize. The expected migration surface is programmatic SDK callers.
Anti-vacuity — the flipped pin shown failing without the fix
Prediction recorded before running: removing the arm makes exactly 4 cases in
automation-write-capability-gate.test.tsfail — theAUTHORING_WRITESrefusal case for toggle, the flipped audit assertion, the deeper-spelling case, and the gate-before-body case — while the toggle anonymous-401 case still passes (that floor is domain-wide and independent of this gate), and the positive control, theisSystembypass and the legacy-execution-door case all still pass because an ungated route answers them the same way.Measured: 4 failed | 22 passed (26) — and the same four cases, by name:
Predicted 4, measured 4, same identities. With the arm restored:
Test Files 1 passed (1) / Tests 26 passed (26).perl -0pi -e "s/\Q...\E//"with an embedded newline inside\Q...\Ematched nothing and exited 0. The on-disk check caught it —arm-count-after=1— and the run aborted itself asMeasurement voidinstead of reporting a green ablation over an unmutated file. The second attempt removed the line by exact string match and provedarm-count-before=1 → arm-count-after=0,numstat 0 1, with the neighbouringparts.length === 0arm still present (so the mutation removed the arm, not the function). Restore confirmed by the trap: arm back on disk = 1,git status --porcelainempty.Mutation proved on disk by grepping the arm text before (
1) and after (0) — a zero-matchperl -0piexits 0, so the edit tool's exit code proves nothing. Restore ran undertrap … EXIT INT TERM, and the fix was committed first so the restore had a real anchor. No rebuild was needed for this ablation and none is claimed: the audit pin imports../http-dispatcher.js— a relative path inside the same package — so vitest compiles the mutated source directly and nodist/sits between the mutation and the measurement.Verification
All at commit
fa0aa1aa,git status --porcelainempty. Every exit code captured before any pipe (cmd > log 2>&1; echo $?), and each gate quoted by its own verdict line, never a bare$?.Touched test files, each run individually
automation-write-capability-gate.test.tsTest Files 1 passed (1)·Tests 26 passed (26)automation-toggle-tenant-scope.dogfood.test.tsTest Files 1 passed (1)·Tests 8 passed (8)automation-toggle-unknown-flow.test.tsTest Files 1 passed (1)·Tests 6 passed (6)http-dispatcher.test.tsTest Files 1 passed (1)·Tests 246 passed (246)automation-body-validation.test.ts(drives toggle, untouched)Test Files 1 passed (1)·Tests 18 passed (18)automation-register-error-class.test.ts(drives toggle, untouched)Test Files 1 passed (1)·Tests 9 passed (9)pnpm --filter @objectstack/runtime typecheckandpnpm --filter @objectstack/dogfood typecheckboth exit 0, and both logs echo> tsc --noEmit— checked deliberately, because a--filtermatching zero scripts exits 0 and reads as a pass.Gate union — derived with
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack(no paths; the script takes its own change set from the merge base) and re-derived on the final diff: 8 paths, 20 path-matched families + 6 convention-triggered, unchanged between the two derivations. All run, all green. Verdict lines:check-adr-0087-registration—✓ 1 declared-breaking changeset(s), each carrying an ADR-0087 disposition·not-required (no-migration-prescription)check:type-check-debt—OK — 32 ledger entr(ies) re-measured in 237.1s, 1898 raw tsc error(s) total, none above its recorded number— MEASURED, not refused, after the full closure build (Tasks: 71 successful, 71 total)check:slot-lookup—✓ ratchet holds: 107 unswept site(s) in 25 file(s), none newcheck:engine-double-contract—OK·check:where-matcher—OK·check:query-options-erasure—✓check-cross-package-test-inputs—OK: 16 package(s) read outside themselves, all declaredcheck-ci-filter-parity—OK: all 95 declared cross-package glob(s) (80 unique) are coveredcheck:route-envelope—✓ self-test passed·check:published-files—✓·check:test-source-alias—OK — 72 packages with tests scannedcheck:type-source-resolution—OK — 77 packages with a tsconfig.json scanned·check:type-check-coverage—✓check-plugin-teardown-shape—✓ 63 Plugin implementation(s) across 4583 source(s)check-empty-changeset—✓ No empty-frontmatter changeset introduced·check-changeset-no-major—✓ no major bump·check:objectui-changeset—✓·check:changeset-gate-self-tests—✓ 118 assertionsdocs-audit/check-affected-docs—✓ 381 cases pass·release-rehearsal-clone --self-test—✓check:empty-state✓ all classified,check:liveness✓,check:strictness-ledger✓ 60 file(s),check:variant-docs✓ 18 discriminated union(s)check:nul-bytes—✓ 75 assertions, plus a manual control-byte grep over all changed paths: no hits⛔ Not claimed: the repo-wide
pnpm lint. CI owns that run. No other narrowing — every family the derivation named was run at the final head.Generated by Claude Code
Generated by Claude Code