Skip to content

fix(runtime): mount POST /automation/:name/clone on the dispatcher bridge, at both bases - #20779

Merged
objectstack-fleet[bot] merged 9 commits into
mainfrom
claude/issue-20676-mount-flow-clone
Sep 30, 2026
Merged

objectstack-fleet[bot] merged 9 commits into
mainfrom
claude/issue-20676-mount-flow-clone

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #20676
Clause-②: no

What was broken

ADR-0126 §7.1's flow clone door answered 404 ENDPOINT_NOT_FOUND on every live server, for every caller and every body. The domain arm (packages/runtime/src/domains/automation.ts, POST /:name/clone) exists, but registerAutomationRoutes in packages/runtime/src/dispatcher-plugin.ts mounts every /automation route explicitly and never mounted this one, so the transport's notFound answered before dispatch() ran. The arm's unit test (domains/automation-flow-clone.test.ts) stayed green because it drives HttpDispatcher directly, below the mount. The route was also missing from route-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/clone mounted beside /:name/toggle, dispatching to POST /automation/:name/clone. registerAutomationRoutes runs for both bases, so the environment-scoped twin (/api/v1/environments/:environmentId/automation/:name/clone) is mounted by the same line. Registered after trigger/:name: for a flow literally named clone, POST /automation/trigger/clone still reaches the legacy execution door, and either mount rebuilds the identical dispatch path, which the domain answers trigger first.
  • packages/runtime/src/route-ledger.ts: a POST /automation/:name/clone row, server-only, with its rationale (the operational driver is the Setup page, which calls the platform API directly; the same posture as the POST /actions/_activation/:object/:action row). There is no client.automation.clone SDK method, and gap is ratcheted at 0. Census regenerated with --fix: 81 to 82 rows.
  • .changeset/20676-mount-flow-clone.md: @objectstack/runtime patch.

No domain arm, gate, response shape or spec file changed. packages/runtime/src/domains/automation.ts is untouched.

Sweep: domain arms against bridge mounts

Every handleAutomationRequest arm, diffed against the registerAutomationRoutes mounts on origin/main f284ab26:

Domain arm Bridge mount Verdict
POST / (create) POST /automation mounted
GET /actions, GET /connectors, GET /_status the three literal mounts, before /:name mounted
GET /:name, PUT /:name, DELETE /:name /automation/:name x3 mounted
POST /trigger/:name (legacy) /automation/trigger/:name mounted
POST /:name/trigger /automation/:name/trigger mounted
POST /:name/toggle /automation/:name/toggle mounted
POST /:name/clone none mounted by this PR
GET /:name/runs, GET /:name/runs/:runId both mounted mounted
POST /:name/runs/:runId/resume mounted mounted
POST /:name/runs/:runId/cancel, /restore-suspension both mounted mounted
GET /:name/runs/:runId/screen mounted mounted
GET / (flow list) none retired (#19543 door 4), correctly unmounted

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.ts boots the CRM app with the automation service through bootStack (the real Hono app) and clones the shipped crm_convert_lead_wizard. It pins these cases:
    • an anonymous caller gets 401 UNAUTHENTICATED (the domain floor, not the transport 404);
    • a legal clone gets 200 with data.notice === FLOW_CLONE_NOTICE (imported, not restated) and status: 'draft', and the clone reads back on GET /automation/:name;
    • an illegal machine name gets 400 VALIDATION_FAILED, and nothing is registered under it;
    • a missing name gets 400 VALIDATION_FAILED;
    • a taken name gets 409 RESOURCE_CONFLICT.
  • packages/runtime/src/dispatcher-plugin.automation-clone-mount.integration.test.ts covers the environment-scoped twin, which bootStack never mounts because it boots without project scoping. It uses plugin-hono-server and the dispatcher with enableProjectScoping: true over a real socket. The discriminator is the anonymous floor's 401 UNAUTHENTICATED, which only the dispatcher mints. Both bases are probed, with a positive control (/:name/trigger, changed from /:name/toggle in 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).
  • With the row in the ledger, route-ledger-live-mount-parity.dogfood.test.ts now also guards this mount.

Reverse verification (ablation, one-off, nothing left in the tree)

The fix was committed first. scripts/ablation-replace.mjs then renamed the mount path (automation/:name/clone became automation/:name/clone-ablated-20676, anchor 1 to 0). Runtime was rebuilt, and ablation-dist-preflight.mjs found the marker in dist/index.js and dist/index.cjs.

  • The runtime pin went red on the 2 clone cases, each with 404 {"code":"ENDPOINT_NOT_FOUND"}. Both controls stayed green.
  • The dogfood pin went red on 5 of 5 cases, each with 404 ENDPOINT_NOT_FOUND, the card's own symptom byte for byte.
  • The ledger parity gate went red on 2 cases: POST /automation/:name/clone — LEDGERED BUT NOT MOUNTED, and the ablated mount unledgered.

Restore: the blob equals the HEAD blob (b6dc62c9), whole-tree git status --porcelain is empty, runtime was rebuilt, and --absent preflight shows the marker absent from all 6 built files. Re-run: runtime pin 4/4 green; dogfood (the clone pin, the ledger parity gate and automation-toggle-tenant-scope) 21/21 green.

Downstream prose this makes true

  • The FLOW_DISABLED refusal ("...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.mdx promises "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:

    • after a 200 clone, GET /api/v1/meta/flow/CLONE answers 404 RESOURCE_NOT_FOUND, while the source answers 200;
    • after a cold boot on the same database file, GET /api/v1/automation/CLONE answers 404, while the source answers 200.

    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

  • Fixed here (a bounded in-place fix, patch round 1): the /automation enforcement prose in packages/qa/dogfood/test/authz-conformance.matrix.ts said "four gated flow writes". It now names five, adding the ADR-0126 §7.1 clone POST /:name/clone. Evidence: isFlowAuthoringWrite in packages/runtime/src/domains/automation.ts returns true for exactly five route shapes: POST / (parts.length 0), and POST /:name/toggle, POST /:name/clone, PUT /:name and DELETE /:name (parts.length 1).
  • Fixed here (a bounded in-place fix, patch round 1): the note on route-ledger.ts's POST /automation/:name/toggle row.
    • BEFORE: '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'.
    • AFTER: 'No organization wall scopes the enabled bit: toggleFlow writes the ADR-0126 §7.2 activation ledger first — one deployment-wide sys_metadata_activation row 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, which getFlowRuntimeStates() reads with no caller and no organization; the automation service is ONE instance per environment'.
    • Evidence: toggleFlow in service-automation's engine.ts calls flowActivationStore.setActive before it updates flowLedgerDisabled, and core's metadata-activation-store.ts has the columns metadata_type, name, package_id and active, matched on (metadata_type, name).
    • "Packaged flows only" is NOT added here: that is PR fix(service-automation)!: the toggle door switches packaged flows only; a customer flow is refused, naming its status switch (#20726) #20780's behaviour, and whichever of the two PRs lands second adds it.
  • The dogfood census pins were re-derived by the census file's own method (patch round 1). In authz-probe-blind-spot.census.ts, the route-ledger.ts probe row went from population/reach 81/81 to 82/82, with the blind spot 0 and keys 21 unchanged; BLIND_SPOT_TOTAL_STATIC 67 / _RUNTIME 72 are unchanged. The authz-conformance.matrix.ts docblock 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/clone is 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 of origin/main over ab7d5015 and 5518c808):

  • Runtime pins (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.
  • The full dogfood package: 141 files passed and 1 skipped (142); 1155 tests passed and 3 skipped. The two formerly red files (authz-conformance.test.ts, and authz-probe-blind-spot.test.ts, the shard-3 file) pass.
  • dispatch-gates --ran reconciles 67 of 67 with 0 NOT-MEASURED. check:route-ledger-census reads 82, and the array holds 82.

Round 0 (head 0b1c343e), kept for the record:

Head 0b1c343e. The full runtime suite ran at d663c2fe, whose only difference from 0b1c343e is one string in the new ledger note. Every suite that reads the ledger was re-run at 0b1c343e.

  • pnpm --filter @objectstack/runtime exec vitest run --project local --maxWorkers=2 at d663c2fe: 291 files passed, 4204 tests passed, 1 skipped.

  • At 0b1c343e: route-ledger.conformance, automation-api-contract-mounts, the new clone-mount pin and domains/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 --noEmit plus check:test-typecheck) and pnpm --filter @objectstack/dogfood typecheck: both green at 0b1c343e. tsc --listFiles confirms each program contains its new test file (1 hit each).

  • dispatch-gates --commands (67 commands) at d663c2fe: 64 exited 0. The other three were resolved as follows:

    • check:doc-authoring was a real finding: a tracker id inside the new ledger note string. It is removed in 0b1c343e, and the gate now exits 0.
    • check-plugin-teardown-shape --self-test refused on the shallow clone. After fetching its pinned fixture commit it passed, 48 cases.
    • check:dual-build-cjs-loads refused with a prerequisite error: 8 packages outside the build closure had no dist/. They are now built.

    The final-head re-run of all 67, and the --ran reconciliation, are in the report comment on the card.

  • Lint, as a proven narrowing and not a full pnpm lint. eslint --format json over the 4 touched TS files returned 4 results, 0 errors, 0 warnings. Each file is inside eslint's own population (--print-config returns 6/6/5/5 rules). eslint.config.mjs enables no type-aware linting (no parserOptions.project, no projectService), 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-tree pnpm lint is left to CI.


Generated by Claude Code

…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>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Sep 30, 2026
@github-actions

github-actions Bot commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/runtime, touching 8 documentable anchor(s).

4 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/client-sdk.mdx (via automation.trigger (sdk, the route ledger binds it to POST /automation/trigger/:name))
  • content/docs/api/plugin-endpoints.mdx (via /automation/trigger/:name (route, a path literal in note))
  • content/docs/deployment/production-readiness.mdx (via createDispatcherPlugin (symbol, a top-level function))
  • content/docs/plugins/packages.mdx (via createDispatcherPlugin (symbol, a top-level function))

⛔ 1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17/17-3.mdx (via automation.toggle (sdk, the route ledger binds it to POST /automation/:name/toggle, selected by route anchor /:name/toggle), automation.trigger (sdk, the route ledger binds it to POST /automation/trigger/:name), /:name/toggle (route, a path literal in a comment in createDispatcherPlugin))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 26 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 085ca6bc1c446e6484713823ce92284b4ec0ad54 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 9c40f1294921477b9652dcbcab36022393822488 — the merge of head 99b3cfa42a64f82aee7caa3bf80ad50e09624b79 into base 085ca6bc1c446e6484713823ce92284b4ec0ad54, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 085ca6bc1c446e6484713823ce92284b4ec0ad54 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…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>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 99b3cfa42a64f82aee7caa3bf80ad50e09624b79
Local-runs: none

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 /:name/trigger control, the two bounded in-place fixes and the final-head readings. The seat's spot checks on the head:

  • 7 files, +318 / −8;
  • FLOW_NOT_FOUND_STATUS = 404 (flow-dispatch-status.ts:138) and FLOW_CLONE_NAME_TAKEN_STATUS = 409 (flow-clone.ts:160);
  • route-ledger.ts carries 82 route: rows.

① Derived judgments

  1. The mount (dispatcher-plugin.ts, in registerAutomationRoutes): POST ${base}/automation/:name/clone dispatches with the same shape as /:name/toggle. It is registered after trigger/:name, so POST /automation/trigger/clone still reaches the legacy execution door. It covers both bases on the family-wide scoping rule. RIGHT: it adds transport reach to an existing arm and no accept set of its own. The arm reads no query parameter, so the closed-query-set rule does not apply.

  2. The changeset (@objectstack/runtime patch), each claim judged against the clone arm and flow-clone.ts at the head. RIGHT on every claim:

    • 200 { flow, notice };
    • 400 for a missing or illegal name or label, through the body check and the engine's flowDefinitionRefusal;
    • 404 for an unknown source;
    • 409 RESOURCE_CONFLICT, including the same-name case;
    • 401 from the anonymous floor, the first statement of the handler;
    • 403 for a caller without manage_metadata, through isFlowAuthoringWrite and refuseUngrantedFlowWrite, unchanged since the clone arm landed;
    • "No request or response shape changed".

    It claims nothing about persistence, Studio reach or restart survival, which is right to omit (see ③). Precision remark, not a defect: under projectResolution: 'required' only the scoped base is registered, as for every sibling route.

  3. The ledger row for POST /automation/:name/clone is server-only, with a note. RIGHT on shape and content:

    • there is no client.automation.clone, and the note mirrors the _activation row's posture;
    • the body is { name, label }, mandatory and closed;
    • there is no ancestry, and the manage_metadata gate is fail-closed;
    • it is not the §5 activation gate.

    The census moves 81 → 82, and check:route-ledger-census is green.

  4. The corrected toggle note: toggleFlow writes the ADR-0126 §7.2 activation ledger row first, then the in-process projection. RIGHT against engine.ts toggleFlow and core's metadata-activation-store.ts. The edit is inside the claim's surface.

  5. Contract widening: none. ADR-0126 §7.1, the domain arm, the Setup page and the docs already declare this door, and the mount changes nothing the arm answers. Clause-②: no holds.

  6. Pins. RIGHT:

    • The dogfood bootStack pin covers 401, 200 with FLOW_CLONE_NOTICE plus a read-back, 400 illegal, 400 missing, and 409.
    • The runtime integration pin covers both bases, with a /:name/trigger positive control and a transport-404 negative control. Its discriminator (a 401 that only dispatch() mints) cannot appear without the mount.
    • The reported ablation reddened both pins and the ledger parity gate.
  7. The census and matrix figures (82/82, blind spot 0, keys 21), and the matrix prose "five gated flow writes": RIGHT. Both files were added to the claim's surface in patch round 1, and the dogfood shards are green on the head.

② Semver level

patch on @objectstack/runtime is right. The diff fixes a bug in a released package and adds no export, no authorable key and no response shape. Clause-②: no with no arm, in the changeset and in the PR body, is right. Check Changeset is green.

③ Boundary flags

  • Round 0 and round 1 deviations: all answered, and none bears on the diff:
    • the base and the merges of main;
    • the two pin homes, both on the claim;
    • the full suites, answered by Test Core, Type Check and Lint & Repo Gates, all green on the head;
    • the fixture object fetch;
    • the model-free trailers on all nine commits;
    • the throwaway D18 file, never committed;
    • the recorded process-group kill;
    • shard membership by emulation.
  • D18 (the clone is engine-only, so it is lost on a cold boot and absent from /meta): escalated to the seat. It is not a defect of this diff, which touches nothing on the persistence path.
  • Matrix prose drift: closed in this diff.
  • FOLLOW-UPS.md §8a D22 goes stale on merge: a docs-only follow-up outside the card's surface, not blocking.
  • The PR body's staleness against the head: escalated to the seat, which applied the carry (see the note above).

Implemented-by: claude/issue-20676-mount-flow-clone
Reviewed-by: session_01VvcEokUG1tvVxkceYfR5XB

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 30, 2026 07:00
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 30, 2026
Merged via the queue into main with commit 96e7244 Sep 30, 2026
44 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-20676-mount-flow-clone branch September 30, 2026 07:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

2 participants