Skip to content

feat(runtime): flow clone action — whole-definition copy, mandatory new name, no ancestry (ADR-0126 §7.1) - #12190

Merged
os-support-ai merged 2 commits into
mainfrom
claude/issue-12156-flow-clone-action
Aug 25, 2026
Merged

feat(runtime): flow clone action — whole-definition copy, mandatory new name, no ancestry (ADR-0126 §7.1)#12190
os-support-ai merged 2 commits into
mainfrom
claude/issue-12156-flow-clone-action

Conversation

@claude

@claudeclaudeBot commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Closes#12156

Adds POST /api/v1/automation/:name/clone — the ADR-0126 §7.1 clone half for flows. An admin who cannot edit a packaged flow in place gets an ordinary org-authored sibling to edit instead. The renderer half rides L5 (objectstack-ai/objectui#6301); this is the door.

The diff

FileWhat
packages/runtime/src/flow-clone.ts(new)The copy itself: the whole-definition copy, the fields it mutates, the keys it must not carry forward, the same-name refusal message, and the notice returned on success.
packages/runtime/src/domains/automation.tsThe route (POST /:name/clone), its arm in isFlowAuthoringWrite, and the route-table doc entry.
packages/runtime/src/domains/automation-flow-clone.test.ts(new)18 tests, including the #11703-shaped deep-equality test and the same-name refusal.
.changeset/olive-crabs-clone.mdminor, @objectstack/runtime (v17 line).

The three rules the ADR makes load-bearing

Whole-definition copy — never param-list assembly.cloneFlowDefinition deep-copies the parsed definition and mutates exactly name/label/status. It enumerates no facet, so adding a facet to FlowSchema needs no edit here. The acceptance test is the #11703 counter-example stated as an assertion: deep equality of the clone against its source minus those three fields, over a fat packaged exemplar (scalars, enum, number, nested node/edge arrays, nested config, nested errorHandling, plus the protection envelope). A spot-check of nodes/edges would be the enumerated list wearing a test's clothes, so the comparison is whole-object.

Deep copy, not a spread: the source is the engine's live FlowParsed out of its flow map, so a shallow copy would leave clone and original sharing one nodes array.

No ancestry (amendment ruling 2, §9). Nothing records what a clone came from — no field on the definition, none on the response. A test pins that the response carries no clonedFrom/source/basedOn-style key and that nothing in it names the source flow at all.

Same-name refused loudly, with the sanctioned path. 409 RESOURCE_CONFLICT; the message names the offending name, the reason (the engine keys flows by bare name, so a second definition silently shadows the other and the survivor depends on registration order), and the remedy (retry under an unused name, with a concrete suggestion). Refused before anything is registered.

Three decisions worth a reviewer's eye

1. The source's ADR-0010 protection envelope is dropped.⚠️ This is the one place the implementation reads more into the ADR than the card spelled out, and the one I'd most like confirmed.

FlowSchema spreads MetadataProtectionFields, so a packaged flow's parsed definition really does carry _packageId, _provenance: 'package', _lock, _lockReason, … Carried onto a clone verbatim, those keys would (a) name the base's package, which is ancestry by another name; (b) make the clone a package artifact, so upgrade/uninstall would re-seed or remove the admin's own work; (c) carry the base's _lock onto the clone, leaving it exactly as uneditable as the flow the admin cloned to get around; and (d) classify the clone as a code artifact to isCodeArtifactBody, the test the boot flow-pull's precedence reads.

So they are stripped, and the strip is derived from MetadataProtectionFields + METADATA_READ_DECORATIONS rather than restated as a literal list — a new envelope key is stripped the day it lands, with no second list to drift.

The consequence for the card's acceptance wording: deep equality is asserted against the source minus the envelope, with the envelope drop pinned by its own separately-named test. If the reviewer would rather the envelope ride across, that is a one-line change here and a card for the ancestry question.

2. status: 'draft', and the response says plainly that this is not an off-switch. The engine disables a flow on status'obsolete'/'invalid' only (engine.tsregisterFlow); 'draft' and 'active' both stay enabled and both get their trigger bound. So a cloned record-change or schedule flow is armed and runs alongside its base. 'obsolete' would have made it inert, but ADR-0126 §7.2 rules clone and disable independent primitives — "not halves of an unfinished ceremony" — so folding a disable in here would invent the ceremony the ADR declined. Stating the fact in the response is the honest alternative, and the notice does that alongside the references sentence §9 requires.

3. Built from getFlow + registerFlow, not a new contract method.IAutomationService lives in packages/spec, which this card excludes, and the door needs nothing the contract does not already offer. Upside: the clone goes through the engine's own registration path, so it is canonicalized and validated exactly as a create is, rather than by a second policy that agrees with the first only until one of them moves. Same reason the refusal code is the standard-catalog RESOURCE_CONFLICT (status→code, 409) — no error-ledger entry, no spec change.

Clause ② — judged NOT triggered; please confirm

This is a new action surface. No existing accept/reject behaviour moves: POST /, PUT /:name, DELETE /:name, POST /:name/toggle and every run route answer exactly as before, and the clone's own mandatory-name and same-name refusals are new-surface validation rather than a tightening of an existing one. The one edit to an existing function is the isFlowAuthoringWrite arm, which only widens the manage_metadata gate to cover the new route (a clone registers flow metadata at environment scope, so leaving it out would have been a bypass of #10145, not a gap in it). Flagged here for the reviewer to confirm, per the dispatch.

Verification

Union run at 2f2e8eca, the final commit.

  • pnpm --filter @objectstack/runtime typecheckTYPECHECK_EXIT=0
  • pnpm --filter @objectstack/runtime exec vitest run src/domains/automation-flow-clone.test.tsTest Files 1 passed (1) · Tests 18 passed (18)
  • Neighbours unaffected: automation-toggle-unknown-flow.test.ts + automation-write-capability-gate.test.ts re-run green alongside (Test Files 3 passed (3) · Tests 50 passed (50)).
  • Derived gate family (node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, which reads the change set from the merge base itself) — including check:route-envelope, the one this diff most directly moves.

Ablation — proving the #11703 test can actually fail. cloneFlowDefinition's body was replaced with an enumerated four-facet assembly (type/nodes/edges/version), the mutation was confirmed on disk by counting both the injected marker (1) and the deleted text (0) — the editor's exit code proves nothing, a zero-hit replace also exits 0 — and the suite went red: Tests 2 failed | 16 passed, the failures being the deep-equality test and the deep-copy test. The restore leg was confirmed byte-identical (cmp YES, marker 0, deleted text back to 1) and green again at 18/18. No rebuild is involved on either leg: the test imports ../flow-clone.js, a relative source specifier vitest resolves to src, so no dist sits between the mutation and the measurement.

Worth recording: only 2 of 18 tests moved under that ablation. The whole-object deep-equality assertion is the only thing standing between this action and #11703's failure mode — which is exactly why the card insisted on it, and why it should not later be "simplified" into per-facet checks.

Not in this PR

No packages/spec change, no objectui change, no ledger write (clone has no ledger involvement — amendment ruling 2). No files shared with PR #12026 (#11997 name-shadow diagnostics): that work sits in packages/objectql and packages/services/service-automation (engine.ts, plugin.ts, index.ts, flow-precedence.ts), and this PR touches neither package.


Generated by Claude Code

…name
`POST /api/v1/automation/:name/clone` (ADR-0126 §7.1): copy a flow's parsed
definition to an ordinary org-authored sibling an admin can edit, for the case
where the packaged original cannot be edited in place.
The copy is whole-definition and never an enumerated facet list — #11703
measured an enumerated clone dropping three of six facets in silence, and a
flow has far more facets than a permission set. Exactly `name`, `label` and
`status` are mutated; the acceptance test asserts deep equality of the clone
against its source minus those three fields, so a dropped facet fails a test
rather than shipping.
The new machine name is mandatory and a same-name clone is refused 409
`RESOURCE_CONFLICT`, naming the reason and the remedy: the engine keys flows by
bare name, so a second definition under one name silently shadows the other and
the survivor depends on registration order.
No ancestry is recorded (amendment ruling 2, §9) — no provenance field on the
definition, none on the response — and the source's ADR-0010 protection
envelope is dropped rather than carried across, so the clone is org-owned and
editable instead of a second copy of the package's locked artifact.
References are not re-pointed; the response says so, along with the fact that
`status: 'draft'` is a lifecycle label and not an off-switch.
The route joins the `manage_metadata` authoring-write set — it registers flow
metadata at environment scope, exactly as `POST /automation` does.
@github-actions

github-actionsBot commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

  • content/docs/api/data-api.mdx(via /:name/clone (route))
  • content/docs/api/declarative-endpoints.mdx(via /api/v1/automation (route))
  • content/docs/api/error-catalog.mdx(via invalid_type (literal), unknown_field (literal))
  • content/docs/api/error-handling-server.mdx(via invalid_type (literal))
  • content/docs/automation/approvals.mdx(via /api/v1/automation (route))
  • content/docs/automation/connectors.mdx(via /api/v1/automation (route))
  • content/docs/automation/flows.mdx(via /api/v1/automation (route))
  • content/docs/deployment/cli.mdx(via invalid_type (literal))
  • content/docs/protocol/kernel/http-protocol.mdx(via /api/v1/automation (route))
  • content/docs/protocol/objectui/concept.mdx(via invalid_type (literal))
  • content/docs/ui/actions.mdx(via /api/v1/automation (route))

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

  • content/docs/releases/implementation-status.mdx(via /:name/clone (route))
  • content/docs/releases/v15.mdx(via /api/v1/automation (route))
  • content/docs/releases/v17.mdx(via invalid_type (literal), unknown_field (literal), /api/v1/automation (route))

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
  • the SDK route bridge reached 45 of 222 client-bound route-ledger rows — the other 177 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run: 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.

Coarse fallback — 23 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 c02fa2214049789405ff93316fdcae6e3df38ab3packageMentionDocs.

Which tree this was computed on

This run read content/docs from 11f08a191927124418533b48df1691b02c2236fa — the merge of head 032d2e0f19049e4ba82ea1950b1a3c26d874806c into base c02fa2214049789405ff93316fdcae6e3df38ab3, 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 11f08a191927124418533b48df1691b02c2236fa && git checkout 11f08a191927124418533b48df1691b02c2236fa
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin c02fa2214049789405ff93316fdcae6e3df38ab3 032d2e0f19049e4ba82ea1950b1a3c26d874806c && git checkout -B drift-repro c02fa2214049789405ff93316fdcae6e3df38ab3 && git merge --no-ff 032d2e0f19049e4ba82ea1950b1a3c26d874806c
node scripts/docs-audit/affected-docs.mjs --json c02fa2214049789405ff93316fdcae6e3df38ab3

⚠️ 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 c02fa2214049789405ff93316fdcae6e3df38ab3 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@os-support-aiClaude

Copy link
Copy Markdown
Collaborator

CI note: if "Type Check · consumer gates" comes back red on this PR, it is the base branch's red, not this diff's — base commit 22c42c9b (#12062) closed ObjectStackClient.packages.update's any-return but left its entry in the shrink-only exported-any-returns ledger, so the client consumer gate fails on any tree that runs it. This PR touches packages/runtime only. Fix in flight: #12193; base will be merged in to re-run CI once it lands. — session session_01KWRU3s15AJz7PGW7a7wdCh


Generated by Claude Code

…-flow-clone-action
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KWRU3s15AJz7PGW7a7wdCh
@os-support-ai
os-support-ai marked this pull request as ready for review August 25, 2026 14:48
@os-support-ai
os-support-ai added this pull request to the merge queueAug 25, 2026
Merged via the queue into main with commit c68c670Aug 25, 2026
33 checks passed
@os-support-ai
os-support-ai deleted the claude/issue-12156-flow-clone-action branch August 25, 2026 15:26
os-trump pushed a commit that referenced this pull request Aug 26, 2026
Resolves the two additive conflicts in packages/runtime/src/domains/automation.ts
against #12190 (#12156, the flow clone door), keeping BOTH sides:
* isFlowAuthoringWrite — this branch's delegated toggle arm
(`return isFlowEnablementWrite(parts, method)`) AND main's [#12156] clone
comment block plus `if (method === 'POST' && parts[1] === 'clone')`.
* the route docblock — this branch's `⚑ refused with its OWN sentence
(#11666)` sub-line under the toggle row AND main's `POST /:name/clone` row,
each sub-line under its own row, in table order.
Adds the pin the merge owes: an unentitled `POST /automation/:name/clone` is
refused with the AUTHORING sentence and not the enablement one. The clone door
is a two-segment POST whose verb lives in `parts[1]`, so a future loosening of
isFlowEnablementWrite would hand it this card's own defect on the arm that
arrived after the card.
Also corrects one enumeration this merge falsified: the FLOW_ENABLEMENT_DENY_MESSAGE
docblock and the pin file's header both said the shared sentence reads correctly
for "the three definition writes". Clone is a fourth.
Part of #11666
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HbG3rGVLjZStHQxHDtzJdJ
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

L4: flow clone action — whole-definition copy, mandatory new name, no ancestry (ADR-0126 §7.1)

2 participants

@os-support-ai@claude