fix(runtime): mount POST /automation/:name/clone on the dispatcher bridge, at both bases - #20779
Conversation
…idge The ADR-0126 §7.1 clone arm in domains/automation.ts was never mounted by registerAutomationRoutes, so every flow clone answered the transport's 404 before dispatch() ran. Mounted beside /:name/toggle at both the plain and the environment-scoped base. Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB Co-authored-by: Claude <noreply@anthropic.com>
…P at both bases Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB Co-authored-by: Claude <noreply@anthropic.com>
…string Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 4 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 — 26 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 9c40f1294921477b9652dcbcab36022393822488 && git checkout 9c40f1294921477b9652dcbcab36022393822488
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 085ca6bc1c446e6484713823ce92284b4ec0ad54 99b3cfa42a64f82aee7caa3bf80ad50e09624b79 && git checkout -B drift-repro 085ca6bc1c446e6484713823ce92284b4ec0ad54 && git merge --no-ff 99b3cfa42a64f82aee7caa3bf80ad50e09624b79
node scripts/docs-audit/affected-docs.mjs --json 085ca6bc1c446e6484713823ce92284b4ec0ad54
|
Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB Co-authored-by: Claude <noreply@anthropic.com>
…or the clone row The new POST /automation/:name/clone ledger row moves the runtime ledger's row count 81 -> 82. Re-derived by the census's own method: population and reach move together (82/82), the blind spot stays 0, and the distinct domain keys stay 21. The matrix's /automation enforcement prose now names the five isFlowAuthoringWrite routes, clone included. Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB Co-authored-by: Claude <noreply@anthropic.com>
… toggle row says what toggleFlow writes The scoped-mount pin's positive control now POSTs /:name/trigger instead of /:name/toggle: same shape and same domain-wide anonymous floor, outside every authoring gate, so its answer does not depend on the toggle door's in-flight semantics. The toggle row's note no longer claims toggleFlow writes an in-process map only: it writes the deployment-wide activation ledger first. Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Inputs read:
No governed surface is touched, and there are 326 changed lines. Seat note on adoption: the reviewer read the PR body before the seat carried the dev's patch-round lines into it. The body now carries them: the
① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
Fixes #20676
Clause-②: no
What was broken
ADR-0126 §7.1's flow clone door answered
404 ENDPOINT_NOT_FOUNDon every live server, for every caller and every body. The domain arm (packages/runtime/src/domains/automation.ts,POST /:name/clone) exists, butregisterAutomationRoutesinpackages/runtime/src/dispatcher-plugin.tsmounts every/automationroute explicitly and never mounted this one, so the transport'snotFoundanswered beforedispatch()ran. The arm's unit test (domains/automation-flow-clone.test.ts) stayed green because it drivesHttpDispatcherdirectly, below the mount. The route was also missing fromroute-ledger.ts, so the live-mount parity gate had no row to flag.What changed
packages/runtime/src/dispatcher-plugin.ts:POST ${base}/automation/:name/clonemounted beside/:name/toggle, dispatching toPOST /automation/:name/clone.registerAutomationRoutesruns for both bases, so the environment-scoped twin (/api/v1/environments/:environmentId/automation/:name/clone) is mounted by the same line. Registered aftertrigger/:name: for a flow literally namedclone,POST /automation/trigger/clonestill reaches the legacy execution door, and either mount rebuilds the identical dispatch path, which the domain answerstriggerfirst.packages/runtime/src/route-ledger.ts: aPOST /automation/:name/clonerow,server-only, with its rationale (the operational driver is the Setup page, which calls the platform API directly; the same posture as thePOST /actions/_activation/:object/:actionrow). There is noclient.automation.cloneSDK method, andgapis ratcheted at 0. Census regenerated with--fix: 81 to 82 rows..changeset/20676-mount-flow-clone.md:@objectstack/runtimepatch.No domain arm, gate, response shape or spec file changed.
packages/runtime/src/domains/automation.tsis untouched.Sweep: domain arms against bridge mounts
Every
handleAutomationRequestarm, diffed against theregisterAutomationRoutesmounts onorigin/mainf284ab26:POST /(create)POST /automationGET /actions,GET /connectors,GET /_status/:nameGET /:name,PUT /:name,DELETE /:name/automation/:namex3POST /trigger/:name(legacy)/automation/trigger/:namePOST /:name/trigger/automation/:name/triggerPOST /:name/toggle/automation/:name/togglePOST /:name/cloneGET /:name/runs,GET /:name/runs/:runIdPOST /:name/runs/:runId/resumePOST /:name/runs/:runId/cancel,/restore-suspensionGET /:name/runs/:runId/screenGET /(flow list)The clone door was the only unmounted arm. No undeclared door was found, so nothing was mounted beyond the card.
Pins
packages/qa/dogfood/test/automation-flow-clone-door.dogfood.test.tsboots the CRM app with the automation service throughbootStack(the real Hono app) and clones the shippedcrm_convert_lead_wizard. It pins these cases:401 UNAUTHENTICATED(the domain floor, not the transport 404);200withdata.notice === FLOW_CLONE_NOTICE(imported, not restated) andstatus: 'draft', and the clone reads back onGET /automation/:name;400 VALIDATION_FAILED, and nothing is registered under it;namegets400 VALIDATION_FAILED;409 RESOURCE_CONFLICT.packages/runtime/src/dispatcher-plugin.automation-clone-mount.integration.test.tscovers the environment-scoped twin, whichbootStacknever mounts because it boots without project scoping. It usesplugin-hono-serverand the dispatcher withenableProjectScoping: trueover a real socket. The discriminator is the anonymous floor's401 UNAUTHENTICATED, which only the dispatcher mints. Both bases are probed, with a positive control (/:name/trigger, changed from/:name/togglein patch round 1 so it holds whichever of this PR and PR fix(service-automation)!: the toggle door switches packaged flows only; a customer flow is refused, naming its status switch (#20726) #20780 lands first) and a negative control (an unmounted sibling segment answering the transport 404).route-ledger-live-mount-parity.dogfood.test.tsnow also guards this mount.Reverse verification (ablation, one-off, nothing left in the tree)
The fix was committed first.
scripts/ablation-replace.mjsthen renamed the mount path (automation/:name/clonebecameautomation/:name/clone-ablated-20676, anchor 1 to 0). Runtime was rebuilt, andablation-dist-preflight.mjsfound the marker indist/index.jsanddist/index.cjs.404 {"code":"ENDPOINT_NOT_FOUND"}. Both controls stayed green.404 ENDPOINT_NOT_FOUND, the card's own symptom byte for byte.POST /automation/:name/clone — LEDGERED BUT NOT MOUNTED, and the ablated mount unledgered.Restore: the blob equals the HEAD blob (
b6dc62c9), whole-treegit status --porcelainis empty, runtime was rebuilt, and--absentpreflight shows the marker absent from all 6 built files. Re-run: runtime pin 4/4 green; dogfood (the clone pin, the ledger parity gate andautomation-toggle-tenant-scope) 21/21 green.Downstream prose this makes true
The
FLOW_DISABLEDrefusal ("...or run a clone of it under a new name",service-automation/src/engine.ts) and the Setup page copy now point at a door that answers.content/docs/capabilities/integrations.mdxpromises "switch it off and clone your own to edit in Studio". The clone half is now true. The edit in Studio half is not, as measured on the same harness, one-off and not committed:200clone,GET /api/v1/meta/flow/CLONEanswers404 RESOURCE_NOT_FOUND, while the source answers200;GET /api/v1/automation/CLONEanswers404, while the source answers200.The clone is engine-only. That is FOLLOW-UPS §8a D18, outside this card, and not fixed here. Carrier: automation: a flow created through the authoring door can assert package provenance, and the ADR-0126 guards and the activation ledger then treat it as package-shipped #20761's stage 2. The maintainer's ruling there (
5904938166) pins "a clone of a shipped flow is saved as a tenant row", and the seat has posted this measurement on that card.Acceptance notes
/automationenforcement prose inpackages/qa/dogfood/test/authz-conformance.matrix.tssaid "four gated flow writes". It now names five, adding the ADR-0126 §7.1 clonePOST /:name/clone. Evidence:isFlowAuthoringWriteinpackages/runtime/src/domains/automation.tsreturns true for exactly five route shapes:POST /(parts.length0), andPOST /:name/toggle,POST /:name/clone,PUT /:nameandDELETE /:name(parts.length1).route-ledger.ts'sPOST /automation/:name/togglerow.toggleFlowwrites 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'.toggleFlowwrites the ADR-0126 §7.2 activation ledger first — one deployment-widesys_metadata_activationrow per flow, keyed by(metadata_type, name), carrying the flow's package id and no organization column — and only then updates the engine's in-process projection, whichgetFlowRuntimeStates()reads with no caller and no organization; the automation service is ONE instance per environment'.toggleFlowinservice-automation'sengine.tscallsflowActivationStore.setActivebefore it updatesflowLedgerDisabled, and core'smetadata-activation-store.tshas the columnsmetadata_type,name,package_idandactive, matched on(metadata_type, name).authz-probe-blind-spot.census.ts, theroute-ledger.tsprobe row went from population/reach 81/81 to 82/82, with the blind spot 0 and keys 21 unchanged;BLIND_SPOT_TOTAL_STATIC67 /_RUNTIME72 are unchanged. Theauthz-conformance.matrix.tsdocblock now reads (82 rows / 21 domains).#20679(the packaged-flow lock on PUT/DELETE) is not addressed here. The clone pins use a new, customer-owned name and do not exercise that lock.docs/qa/platform-checklist/FOLLOW-UPS.md§8a D22 ("POST /automation/:name/cloneis unledgered") goes stale when this lands. The file is outside this card's surface. Carrier: none; noted, not filed.Verification
Patch round 1 — final head
99b3cfa4(a merge oforigin/mainoverab7d5015and5518c808):dispatcher-plugin.automation-clone-mount.integration,route-ledger.conformance,automation-api-contract-mounts,domains/automation-flow-clone): 4 files, 31 tests passed. Runtime and dogfood typecheck are green.authz-conformance.test.ts, andauthz-probe-blind-spot.test.ts, the shard-3 file) pass.dispatch-gates --ranreconciles 67 of 67 with 0 NOT-MEASURED.check:route-ledger-censusreads 82, and the array holds 82.Round 0 (head
0b1c343e), kept for the record:Head
0b1c343e. The full runtime suite ran atd663c2fe, whose only difference from0b1c343eis one string in the new ledger note. Every suite that reads the ledger was re-run at0b1c343e.pnpm --filter @objectstack/runtime exec vitest run --project local --maxWorkers=2atd663c2fe: 291 files passed, 4204 tests passed, 1 skipped.At
0b1c343e:route-ledger.conformance,automation-api-contract-mounts, the new clone-mount pin anddomains/automation-flow-clone, 4 files and 31 tests passed. Dogfood: the clone pin and the ledger parity gate, 13/13 passed.pnpm --filter @objectstack/runtime typecheck(tsc --noEmitpluscheck:test-typecheck) andpnpm --filter @objectstack/dogfood typecheck: both green at0b1c343e.tsc --listFilesconfirms each program contains its new test file (1 hit each).dispatch-gates --commands(67 commands) atd663c2fe: 64 exited 0. The other three were resolved as follows:check:doc-authoringwas a real finding: a tracker id inside the new ledger note string. It is removed in0b1c343e, and the gate now exits 0.check-plugin-teardown-shape --self-testrefused on the shallow clone. After fetching its pinned fixture commit it passed, 48 cases.check:dual-build-cjs-loadsrefused with a prerequisite error: 8 packages outside the build closure had nodist/. They are now built.The final-head re-run of all 67, and the
--ranreconciliation, are in the report comment on the card.Lint, as a proven narrowing and not a full
pnpm lint. eslint--format jsonover the 4 touched TS files returned 4 results, 0 errors, 0 warnings. Each file is inside eslint's own population (--print-configreturns 6/6/5/5 rules).eslint.config.mjsenables no type-aware linting (noparserOptions.project, noprojectService), and its only file reads are two baseline JSONs this diff does not touch, so the diff cannot move any untouched file's verdict. The full-treepnpm lintis left to CI.Generated by Claude Code