Skip to content

feat(metadata-protocol)!: the flow save door refuses a retired spelling on a NEW flow; a stored flow keeps the full chain - #23902

Merged
os-zhuang merged 6 commits into
mainfrom
claude/issue-23775-flow-save-door-new-refusal
Oct 16, 2026
Merged

os-zhuang merged 6 commits into
mainfrom
claude/issue-23775-flow-save-door-new-refusal

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #23775

Clause-②: yes (narrowing)

Ruling-ref: 6212082860 (letter B). Claim: 6212892799. Session: session_01TQLy3Yq6vF4SDpTXdr3Uwi.

Governed (Tier H): the diff touches docs/adr/0087-metadata-protocol-upgrade-contract.md (one dated note), so this PR stays draft until an authorised human approves it. Contract review at CONTRACT_REVIEW_TIER (ruling item 4) belongs to the seat.

What changes

saveMetaItem's flow branch (packages/metadata-protocol/src/protocol.ts) now asks one question before it canonicalizes a flow: does this save replay the conversions retired from the authoring funnel? The predicate is flowSaveReplaysRetired:

  • No stored row at the save's address means authored input. The automation engine's canonicalizer is called with { includeRetired: false }, and so is the door's own fallback verdict on a host with no automation service (the build: a script node's undeclared config key passes objectstack validate, compile and registerFlow, then fails every run — the key half of #21898's class (subflow by reading) #21982 parity). A spelling retired from the authoring funnel is therefore not lifted. It meets the flow parse's refusal: 422 INVALID_METADATA, an issue located at the key, with the text os validate prints (a pin compares the two messages for equality). Draft and publish mode alike.
  • A re-save of a stored row keeps the full chain (includeRetired: true). A legacy row stays editable, and the save heals it as before.
  • Server-stated writes of bodies at rest keep the full chain without a store read: source set (the stored-metadata migration), writeFace: 'package-duplicate' and writeFace: 'package-unmanaged-import'. On an engine host their callers canonicalize with the full chain first anyway. On a host with no automation service, judging them as authored would refuse a body an engine host accepts.

AutomationEngine.canonicalizeStoredFlow(name, definition, options?) (packages/services/service-automation/src/engine.ts) takes { includeRetired?: boolean }, default true. registerFlow (the rehydration) passes nothing, so its chain does not change. The read path still serves stored flows verbatim.

The #4580 fallback warning ("was saved WITHOUT canonicalization") is now emitted once the schema gate has accepted the body, not inside the catch that receives the canonicalizer's throw. On a new flow that throw is now usually the retired spelling's refusal, which the gate answers with a 422. A warning emitted there would claim a save that never happened.

ADR-0087 ## Addendum (2026-07-31) gains one dated note naming the predicate. No existing text is changed.

The predicate, measured against the PM's assumptions

Tree: base f7e836da1, origin/main 34b740f2d merged in, head 4a199348b.

  1. Order. The OCC / version read (repo.headAt at the write address, in the block that computes parentVersion) runs after the flow canonicalization. It comes after the schema gate, the runtime authoring gate and ensureOverlayIndex, so it cannot answer the predicate without a reorder. Hoisting it would put a store read in front of every type's 422s. The flow branch therefore makes its own read through the same resolution (SysMetadataRepository.headAt: state, package, inherited draft binding, bundle member). That costs one or two indexed reads per authoring flow save.
  2. Replay sites. In the save door's flow branch, retired entries are replayed only by resolveFlowCanonicalizer() → canonicalizeStoredFlow and by the includeRetired: true fallback, and both now take the predicate's answer. Measured with the card's example, a wait node's loose config.duration (flow-node-wait-event-config-lift): with the window closed the parse refuses it.
  3. Scope. The OCC read of a draft save reads the draft head only. Studio's flow designer saves drafts (objectui 63d00da, StudioDesignSurface.tsx, saveFlowDraft(..., { mode: 'draft', ... })), so its first edit of a stored flow has no draft row. Judged by that read alone, the edit would count as a new flow and be refused, against ruling item 2 ("a legacy row stays editable in Studio"). The predicate therefore reads the door's existing baseline instead: the row the save overwrites, or, for a draft save with no draft yet, the active row the draft overlays. authoredLockFamily and storedBodyForCarryForward already read exactly this. Without the active fallback, the Studio pins go red (ablation leg 3 below). No overlay scope exists for a flow: flow is Regime C, so an artifact-backed flow is sealed (refusePackagedBaseOverride) and never reaches this branch. This reading is the one the dispatch named as a fork. It is set out in the report and in the maintainer brief below, so the approval decides it.
  4. Neighbours. PR feat(spec,automation): FlowRuntimeState.unboundCause classifies an unbound flow row beside its reason #23854 (engine.ts, AutomationEngine, unbound cause) landed before this branch merged origin/main. Git merged it into engine.ts automatically, with no overlap with canonicalizeStoredFlow. This PR does not touch PR feat(spec)!: retire the view form's visibleOn and the page component's visibility as keys; their ADR-0089 renames leave the load path (F4 of #23543) #23836's view-node functions.
  5. No in-repo producer. git grep -nE "config: \{[^}]*(duration|timerDuration|signalName|connectorId|actionId)" f7e836da1 -- examples ':(glob)packages/*/src/**' ':(glob)packages/*/*/src/**' skills content/docs returns 27 lines, exit 0. 19 of them are in test files. The 8 non-test lines are all inside the conversion registry's own entries and docblocks (packages/spec/src/conversions/registry.ts) and packages/spec/src/migrations/registry.ts.template. There are 0 in examples/**, skills/**, content/docs/** or any package's runtime source. Control: the same pattern on packages/spec/src/conversions/registry.ts alone returns 7 lines, exit 0. The card's own pathspec spelling (packages/*/src, no glob magic) returns 0 lines with exit 1 against a tree, which is a pathspec artefact and not a reading.

Pins (ruling item 3)

Pin Protocol (protocol.save-flow-canonicalization.test.ts, engine stub and no-engine host) REST (meta-flow-save-retired-spelling.test.ts, real stack, PUT /api/v1/meta/flow/:name) MCP
New flow + retired spelling → 422 INVALID_METADATA, located, names the new key, nothing stored both hosts; draft mode too; issue text equals the os validate issue config.duration beside the block → nodes.1.config.duration naming waitEventConfig.timerDuration; the bare loose key → nodes.1.waitEventConfig; draft mode too NOT MEASURED (below)
Stored legacy row re-saves and is served both hosts (publish), and a stored draft re-saves in draft mode publish re-save, served by GET; Studio's first draft over the stored active row NOT MEASURED
New flow in the canonical spelling saves both hosts yes NOT MEASURED

The engine option is pinned in service-automation's canonicalize-stored-flow.test.ts. No option heals; includeRetired: false refuses at the written key, or at the block for the bare loose key; a canonical body comes back reference-identical; registerFlow still registers the legacy spelling.

MCP door: no constructible pin in this repository. packages/mcp registers no metadata-write tool. The HTTP surface bridges data and action tools only, and mcp-server-runtime.ts says the authoring tool registry is deliberately not bridged there. The tools that write metadata for an AI (create_metadata / update_metadata) belong to service-ai-studio in the cloud repository (packages/spec/src/system/constants/platform-tool-names.ts), which this session cannot read. A pin there belongs to that repository's suite. Any of those tools that saves through saveMetaItem gets the refusal from this door.

Existing fixtures were re-sorted where a pin had saved a NEW flow in a retired spelling and expected it to heal or to be stored raw. Those pins now seed the stored row they re-save, and their subject (heal, the throw fallback, the #4580 warning, the #21982 verdict) is unchanged. The engine stub now honours includeRetired.

Ablation (one leg per changed behaviour; mutation through scripts/ablation-replace.mjs, wrap mode, restore proven by blob hash == HEAD)

Leg Mutation Red Green after restore
1 predicate flowSaveReplaysRetired answers true for every save (pre-B) protocol 6 failed / 28 passed (the 4 new-flow refusals, the option-call pin, the text-parity pin); REST 3 failed / 3 passed (the 3 refusals). dist rebuilt, ablation-dist-preflight marker present (exit 0) yes
2 engine option includeRetired: options.includeRetired ?? true → true service-automation 2 failed / 38 passed (the two includeRetired: false pins) yes
3 draft fallback the active-row fallback removed protocol 2 failed (Studio's first draft, its throw fallback); REST 1 failed (Studio's first draft). dist rebuilt, marker present yes
4 warning deferral warn inside the catch again protocol 3 failed (the engine-host new-flow refusal's no-warning assertion, and the two #4580 count pins) yes

Leg 4's first attempt was a no-op that the tool refused. The replacement contained the anchor text, so the anchor count stayed 1 → 1 and nothing ran. It was re-run with a non-overlapping replacement. In the restore leg, metadata-protocol was rebuilt from HEAD and ablation-dist-preflight --absent passed for all three markers (exit 0 each). The protocol suite then passed 34/34, REST 6/6 and service-automation 40/40.

Tests

  • @objectstack/metadata-protocol full suite, before the merge: 242 files passed, 3 skipped; 20609 tests passed. After the merge (the merge touched no file in the package), the three flow suites: 96/96.
  • @objectstack/service-automation full suite after the merge: 186 files, 2507 tests passed. Before the merge, 7 files failed to import @objectstack/service-messaging, whose dist/ had not been built. After the rebuild they pass.
  • @objectstack/rest: the new file and three neighbouring flow files, 29/29.
  • typecheck: metadata-protocol (after lastCall replaced Array#at), service-automation and rest: exit 0.

Gates

Local: the 76 families dispatch-gates.mjs --commands derived from the merge base, plus the three artifact-roster gates whose roster sits in one of this diff's directories (check-changeset-fixed, check:authz-resolver, check:error-code-casing) and @objectstack/spec check:generated (spec moved in the merge). All of them ran on the final head and are reconciled in the report. check:dual-build-cjs-loads answers PREREQUISITE NOT MET because it needs the whole tree built, so it is NOT MEASURED locally; CI's Build Core runs it. Everything else that CI runs is cited from this PR's check-runs in the report.

Acceptance notes

  • The spec contract does not name the option. IAutomationService.canonicalizeStoredFlow (packages/spec/src/contracts/automation-service.ts) still declares (name, definition). The option lives on the implementation and on the protocol's structural reader. An implementation that ignores the third argument would heal a new flow silently (option A's behaviour) instead of refusing it. The one implementation in this repository honours the option. Wording the contract is spec-lane work. Carrier: none, noted here only.
  • No ADR anchor names the predicate yet. scripts/adr-anchors/packages__metadata-protocol__src__protocol.ts.json does not mention it, because that file was outside the claim's surface.
  • Two refusals for a bare loose wait key. A wait node with ONLY the loose config.duration is refused at waitEventConfig (the node's own rule names the block and timerDuration). The loose key's own refusal appears only after the block exists. Authors meet the authoring funnel's behaviour in two rounds, here and at os validate alike. This is an observation and was not filed.
  • The saveMetaItem's flow canonicalizer throw-fallback is silent — a publish-mode save that skipped canonicalization looks identical to a clean one #4580 warning can still be wrong. It now fires after the schema gate, so a later gate that refuses the save (runtime authoring rules, the authoring gate) still leaves it standing. That case existed before this change and is narrower now.

维护者速读(草稿)

改了什么:通过元数据 API(Studio、REST、MCP 背后的同一个保存口)新建一个 flow 时,如果它用了已经退役的旧写法(例如 wait 节点把 duration 写在 config 里),保存口不再悄悄改写后照存,而是返回 422 并指出该写哪个新键,内容与 os validate 一致。库里已有的 flow 再次保存时照旧自动改写,老数据在 Studio 里照常能改能存。服务端自己搬运库存数据的写入(迁移、复制包、非托管导入)也照旧。ADR-0087 补了一行日期注记。

为什么改:裁决 B(6212082860)。以前 AI 通过 API 用旧写法建 flow,保存成功、没有任何提示,错误写法会一直留在 AI 的习惯和它学过的文档里。现在作者输入一律给出响亮的拒绝和改法,库存数据照旧兼容,和 os validate 及 os build 的口径一致。

风险与代价(含回滚):

  • 部署里如果有客户端通过 API 用旧写法新建 flow,下一次会收到 422 和改法,这是预期效果,但对它是可见的变化。仓内测得零写入者。
  • 判定「库里有没有这行」时,草稿保存会看它所覆盖的已发布行:Studio 的流程设计器按草稿保存,所以第一次编辑一个老 flow 算作再次保存,不算新建。这一点是本 PR 对裁决措辞的落地解读(裁决原文写的是「用保存口已有的版本读取」,但那次读取发生在改写之后,而且草稿保存时看不到已发布行),请在批准时一并确认。
  • 每次作者保存 flow 多一到两次带索引的读取。
  • 回滚:revert 本 PR 即可恢复「全部照抬照收」,没有数据迁移,也不改任何已存数据。

席位意见:

你要做的:确认上面「草稿看已发布行」和「服务端搬运写入照旧」两处解读,然后批准(Tier H,ADR 一行)。MCP 门的同款测试在 cloud 仓,需另行安排。


Generated by Claude Code

@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Oct 16, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/metadata-protocol, @objectstack/service-automation, touching 7 documentable anchor(s).

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

  • content/docs/concepts/metadata-lifecycle.mdx (via ObjectStackProtocolImplementation (symbol, a top-level class), saveMetaItem (symbol, a method of class ObjectStackProtocolImplementation))
  • content/docs/deployment/validating-metadata.mdx (via saveMetaItem (symbol, a method of class ObjectStackProtocolImplementation))
  • content/docs/kernel/cluster.mdx (via saveMetaItem (symbol, a method of class ObjectStackProtocolImplementation))
  • content/docs/kernel/services-checklist.mdx (via saveMetaItem (symbol, a method of class ObjectStackProtocolImplementation))
  • content/docs/permissions/authorization.mdx (via saveMetaItem (symbol, a method of class ObjectStackProtocolImplementation))

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

  • content/docs/releases/v16.mdx (via AutomationEngine (symbol, a top-level class), ObjectStackProtocolImplementation (symbol, a top-level class))
  • content/docs/releases/v17/17-0.mdx (via AutomationEngine (symbol, a top-level class), ObjectStackProtocolImplementation (symbol, a top-level class))
  • content/docs/releases/v17/17-6.mdx (via AutomationEngine (symbol, a top-level class))

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 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 — 14 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 570f01d270a37aeadfe078a6892797428151a7a8 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 94b77b47982ca81cb2969f3d230538f571c55e66 — the merge of head 4a199348b9729b7f3e2a52d5340789a1d284cba7 into base 570f01d270a37aeadfe078a6892797428151a7a8, 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 94b77b47982ca81cb2969f3d230538f571c55e66 && git checkout 94b77b47982ca81cb2969f3d230538f571c55e66
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 570f01d270a37aeadfe078a6892797428151a7a8 4a199348b9729b7f3e2a52d5340789a1d284cba7 && git checkout -B drift-repro 570f01d270a37aeadfe078a6892797428151a7a8 && git merge --no-ff 4a199348b9729b7f3e2a52d5340789a1d284cba7

node scripts/docs-audit/affected-docs.mjs --json 570f01d270a37aeadfe078a6892797428151a7a8

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 4a199348b9729b7f3e2a52d5340789a1d284cba7
Local-runs: none
Scope: docs/adr/0087-metadata-protocol-upgrade-contract.md, packages/metadata-protocol/src/protocol.ts, packages/metadata-protocol/src/protocol.save-flow-canonicalization.test.ts, packages/services/service-automation/src/engine.ts, packages/services/service-automation/src/canonicalize-stored-flow.test.ts, packages/rest/src/meta-flow-save-retired-spelling.test.ts, .changeset/23775-flow-save-door-new-refusal.md + packages/metadata-protocol/src/sys-metadata-repository.ts (headAt, resolveWriteHead), packages/spec/src/conversions/apply.ts (applyConversionsToFlow, includeRetired), packages/spec/src/conversions/registry.ts (the 13 retiredFromLoadPath flow entries, flow-node-http-callout-rename), packages/spec/src/conversions/conversions.test.ts (the kept-without-ruling list), packages/spec/src/contracts/automation-service.ts (IAutomationService.canonicalizeStoredFlow?), packages/metadata-core/src/item-key-discriminators.ts (itemDiscriminator), packages/runtime/src/domains/automation.ts (registerAndSaveFlow, the clone door), packages/runtime/src/flow-clone.ts (cloneFlowDefinition), packages/cli/src/utils/organization-ownership-apply.ts (the ceremony's door.save types), packages/mcp/src (no metadata-write tool), packages/spec/src/system/constants/platform-tool-names.ts, objectui at the pinned .objectui-sha daeb77fc84 — packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx (saveFlowDraft, doCreateFlow, withStartTrigger, publishDraft) and skeletons.ts (buildFlowSkeleton)

Read against ruling 6212082860 (letter B, six items), the card body and all five comments, PR #23902's body and seven-file list, the net three-dot diff against main at 570f01d27 (+692 / −57, identical to GitHub's file list), the head's check-runs, and the os-dev-report 6215320435.

① Derived judgments

  1. Ruling item 2, the new-flow arm — right. saveMetaItem's flow branch (protocol.ts head :23031) asks flowSaveReplaysRetired once, before the canonicalizer is resolved, and hands the answer to BOTH hosts: the engine call canonicalizeFlow(request.name, request.item, { includeRetired }) and the no-engine fallback applyConversionsToFlow(request.item, { includeRetired, excludeConversionIds: FLOW_FALLBACK_VERDICT_NOT_REPLAYED }) (:23100). With the window closed, apply.ts:156 skips every retiredFromLoadPath entry, the spelling stays in place, and the schema gate (:23130) refuses it 422 INVALID_METADATA with located issues. On an engine host the strict parse throws first; the throw is caught, result is dropped, and the same fallback verdict reaches the same 422 — the build: a script node's undeclared config key passes objectstack validate, compile and registerFlow, then fails every run — the key half of #21898's class (subflow by reading) #21982 parity holds on both hosts. The text-equality pin against ObjectStackDefinitionSchema.safeParse(normalizeStackInput(...)) proves the prescription is the authoring funnel's own. Accept-set change: the API save door's accept set for a NEW flow narrows by exactly the 12 retired flow spellings the loader stopped lifting (see ② item 5) — the change ruling item 4 declares.
  2. Ruling item 2, the re-save arm — right. A stored row at the save's address answers true, so the full chain (includeRetired: true) is replayed exactly as before; the engine stub and the real-stack REST pins both re-save a seeded legacy row and read it back served. The engine's rehydration is untouched: registerFlow (engine.ts head :4642) still calls canonicalizeFlow(name, definition) with no options, and options.includeRetired ?? true keeps the chain open for every caller that passes nothing (:4595). The read path is untouched: no hunk reaches getMetaItem/getMetaItems, and the "read path serves stored flows verbatim" sentence stands at :22989.
  3. Ruling item 1, the predicate — right in substance, with ONE named deviation from the parenthetical, and the deviation is the ruling's own premise falsified by a measurement, not a re-ruling. The ruling says the predicate is read "with the lookup the door already makes for its baseline (the OCC / version read)". Measured on the head: the OCC read (writeHead = await repo.headAt(...), :23517) sits after ensureOverlayIndex() (:23381) and after the schema gate, so it runs AFTER the canonicalization it would have to decide, and for a draft save it reads the draft head alone. The implementation therefore reads the OTHER baseline the door already makes — authoredLockFamily (:22221-22225) and storedBodyForCarryForward (:9443-9455): headAt at the save's own state and package, then for a draft save with no draft row the ACTIVE row at the inherited binding target.packageId. The predicate's two reads are byte-for-byte the shape of those two readers (same ref with org: 'env', same itemDiscriminator member, same packageId ?? null then target.packageId), it runs at the same position in the method as authoredLockFamily (before ensureOverlayIndex, with that precedent), and headAt is the write-address resolution put itself locks against (sys-metadata-repository.ts resolveWriteHead), so "the row the save overwrites" is read with the repository's own answer, draft inheritance (SqlDriver org auto-scope makes SysMetadataRepository.listDrafts blind to env-wide drafts (organization_id IS NULL ∧ injected org = always empty); mode=draft saves drop package_id #11087, metadata-protocol: three package-axis gaps PR #23290's review leaves open — the registry heal after a per-package delete, the draft save's storage-order bind with two packages, and the delete _lock gate that takes no package #23297, metadata-protocol: a draft save naming no package, of an item never published into a package whose only draft is bound to package Q, writes a package-unbound draft beside it, while the publish and discard naming none act on Q's (after PR #23654) #23657) included. The main clause of item 1 holds; the parenthetical could not. The draft-to-active fallback is what item 2's "a legacy row stays editable in Studio" requires: verified at the pinned objectui daeb77fc84, StudioDesignSurface.tsx:6944 and :7009 save flows with { mode: 'draft', packageId: draftPackageId }, so Studio's first edit of a stored active flow has no draft row and would be judged new without it (ablation leg 3 is the dev's measurement of exactly that). The mode does not change the predicate's meaning — new refuses in both modes (pinned in draft and publish, both hosts, protocol and REST).
  4. Ruling item 2's "a legacy row stays editable in Studio" — right, and Studio's CREATE is safe too. doCreateFlow (pinned objectui :6864) writes buildFlowSkeleton (start → end, canonical keys only) plus withStartTrigger (config.triggerType / config.objectName on the start node — declared keys, no retired spelling) with status: 'obsolete', so a new Studio flow meets no refusal. Studio's publish is a promotion (shellClient.publishDraft('flow', …) → publishMetaItem → promoteDraftForPublish, which never calls saveMetaItem), so no Studio path reaches the flow branch in publish mode.
  5. The server-stated writes exemption (source set, writeFace: 'package-duplicate' | 'package-unmanaged-import' answer true with no read) — right; not named by any ruling item, consistent with the governing text. The ruling's letter speaks only of a flow "with no stored row at the save's address" and of "a re-save of an existing row"; a package duplicate writes a copy of a stored row to a NEW address and an unmanaged import writes a built artifact's flows to names no row held, so the literal predicate would judge both as authored. Read in the code: duplicatePackage (:27639) and importUnmanagedPackage (:27873, prepared[].item = result.storable) both pre-canonicalize through the engine's full chain where an engine is reachable, and migrateStoredMetadata (:24265) and flow-credential-migration.ts:222 state source: 'migrate-stored' for rewrites of rows at rest. The ADR-0087 2026-07-31 addendum names duplicatePackage among the seams that "replay the FULL chain, retired entries included" and rules that "a row at rest has no author to teach"; judging these as authored would refuse, on a no-engine host only, what an engine host accepts — the verdict split build: a script node's undeclared config key passes objectstack validate, compile and registerFlow, then fails every run — the key half of #21898's class (subflow by reading) #21982 closed — and would refuse data at rest, which is option C, not taken. Only these three faces are exempt; 'meta-envelope' (REST), 'meta-dispatch' (runtime dispatcher) and 'external-import' (object only) stay authoring faces. The exemption is a reading the maintainer can confirm (③ item 2); it is not a deviation from a ruling item.
  6. Every other in-repo flow writer reaches the right verdict — right. registerAndSaveFlow (runtime automation.ts :1802, the POST /automation definition and POST /:name/clone doors) registers in the engine first (full chain) and then calls saveMetaItem({ type: 'flow', name, item: definition }) with no face: a NEW definition carrying a retired spelling is refused by the door and the registration is withdrawn (restoreFlowRegistration), so the changeset's "every caller of the protocol" holds; a clone copies the engine's live FlowParsed (cloneFlowDefinition deep-copies getFlow(name), which registerFlow stored post-chain), so a legacy source clones in the canonical spelling and meets no refusal. The organization-ownership ceremony's door.save writes position, permission, view and email_template only — no flow. packages.ts:1787 writes app. No in-repo writer is regressed.
  7. AutomationEngine.canonicalizeStoredFlow(name, definition, options?) — right; ruling item 4's "service-automation canonicalizer option". An additive optional third parameter on a public class method, default true, threaded into the private canonicalizeFlow; the default-flip exclusion (CONVERSIONS_NOT_REPLAYED_AT_REHYDRATION) applies whatever the option says. Public-surface change: a widening of @objectstack/service-automation's runtime surface. The spec contract IAutomationService.canonicalizeStoredFlow?(name, definition) (automation-service.ts:853) is NOT changed — compatible (an implementation may take an extra optional parameter; Type Check green), and the protocol reads the method structurally and passes options only when it holds one (resolveFlowCanonicalizer, :6576). The gap this leaves is a boundary flag (③ item 4), not a defect of this PR's lane.
  8. The saveMetaItem's flow canonicalizer throw-fallback is silent — a publish-mode save that skipped canonicalization looks identical to a clean one #4580 warning relocation — right, implied by B. flowCanonicalizeFallback is captured in the catch and said only after the schema gate has accepted the body (:23216), deduped by the same key. On a new flow the canonicalizer's throw is now usually the retired spelling's refusal, which the gate answers 422; "was saved WITHOUT canonicalization" there would have claimed a save that never happened. Outside the claim's strict flow-branch lines, inside protocol.ts and flow-only; the pre-existing case of a later gate refusing after the warning is narrowed, not widened (dev's acceptance note, accurate).
  9. Ruling item 3, the pins — right for protocol and REST; MCP NOT MEASURED, and not constructible here. Protocol (protocol.save-flow-canonicalization.test.ts, 34 tests): new flow with a retired spelling → 422 at nodes.0.config.filters naming filter, nothing stored, no saveMetaItem's flow canonicalizer throw-fallback is silent — a publish-mode save that skipped canonicalization looks identical to a clean one #4580 warning, on an engine stub that honours includeRetired and on a no-engine host, in draft and publish mode; canonical new flow saves; stored legacy row re-saves and is served; stored legacy DRAFT re-saves; the option spy sees { includeRetired: false } on the new flow and { includeRetired: true } on the re-save; issue-text equality with the authoring funnel; the two server-stated faces keep the full chain. Existing [P2] A Studio edit heals every legacy row except a flow's — saveMetaItem re-persists the pre-protocol dialect an author was served #4542 / build: a script node's undeclared config key passes objectstack validate, compile and registerFlow, then fails every run — the key half of #21898's class (subflow by reading) #21982 pins now seed the row they re-save (seedStoredFlow), their subject unchanged. REST (meta-flow-save-retired-spelling.test.ts, real stack, better-sqlite3 memory, 6 tests): the loose key beside its block → 422 at nodes.1.config.duration naming waitEventConfig.timerDuration; the bare loose key → 422 at nodes.1.waitEventConfig naming timerDuration (the node rule answers first — the same two-round behaviour os validate has, as the dev notes); draft mode refuses too; canonical saves; stored legacy row re-saves and GET serves it; Studio's first draft over a stored active row saves. Engine (canonicalize-stored-flow.test.ts, 6 tests): no option lifts; false refuses at the written key or at the block; false is a no-op (reference-identical) on a canonical body; true equals the default; registerFlow still registers the legacy spelling. MCP: packages/mcp/src registers no saveMetaItem caller and no create_metadata / update_metadata tool — those names live in platform-tool-names.ts:60,83 for the cloud repository's service-ai-studio; a pin there is outside this repository (③ item 3).
  10. Ruling item 5, the order — right. F2 landed on main (f606ba2e2, PR feat(spec)!: a job retryPolicy with no attempts is refused at maxRetries; retry-policy-converged leaves the load path (F2 of #23543) #23810) before the branch's merge of origin/main 34b740f2d; retry-policy-converged is retiredFromLoadPath: true at head (registry.ts:4287) and therefore in the changeset's marker and migration list. flow-node-http-callout-rename (F5-http) is still live-window (conversions.test.ts:404, the kept list) and correctly absent — under B it meets the same refusal on the day its own PR retires it, with no change here. PR feat(spec,automation): FlowRuntimeState.unboundCause classifies an unbound flow row beside its reason #23854's engine.ts hunks merged cleanly and the diff touches only the canonicalizeStoredFlow / canonicalizeFlow region; no view-node function of PR feat(spec)!: retire the view form's visibleOn and the page component's visibility as keys; their ADR-0089 renames leave the load path (F4 of #23543) #23836 is touched.
  11. Ruling item 6, the ADR line — right. One dated blockquote note plus one blank line, inside ## Addendum (2026-07-31) (head :534-575, note at :574), naming the predicate, the draft-overlays-active clause, the 422 text and the server-stated exemption; no existing line changed (+2 / −0). The addendum is not rewritten.
  12. The 13th retired flow entry is rightly absent. flow-decision-mode-inclusive-explicit (registry.ts, toMajor 18, retired) is a DEFAULT FLIP excluded by name at both seams regardless of includeRetired (FLOW_FALLBACK_VERDICT_NOT_REPLAYED, protocol.ts; CONVERSIONS_NOT_REPLAYED_AT_REHYDRATION, engine.ts; ruling letter C on A decision node with no declared config.conditions takes EVERY out-edge whose condition holds, in parallel — nothing enforces or warns that intended-exclusive edges partition #15429), so the door never replayed it and this PR changes nothing about it — its absence from the changeset's marker and migration list is correct, and the dev's "12 retired flow D2 ids" count is the right one.
  13. The confidence-gap premise — holds. No in-repo producer writes a retired flow spelling (card premise 3, re-run by the director and the dev; the only non-test hits are the registry's own entries and migrations/registry.ts.template); the designer writes blocks (item 4 above). The visible change is confined to an external client that CREATES a flow in a retired spelling, which is the ruling's intended effect.

② Semver level

  • .changeset/23775-flow-save-door-new-refusal.md: '@objectstack/metadata-protocol': minor, '@objectstack/service-automation': minor; header feat(metadata-protocol)!:; body carries Clause-②: yes (narrowing) — the same line the PR body carries — a **BREAKING (API save door)** paragraph, the FROM → TO migration for every refused spelling, and exactly one ADR-0087 marker, adr-0087: not-required (already-registered …) naming the 12 retired flow D2 ids.
  • Level — right. metadata-protocol publishes a narrowed runtime accept set (BREAKING under AGENTS.md §Post-Task 3: "(narrowing) is BREAKING"); major is barred in the launch window (scripts/check-changeset-no-major.mjs, question 1), and every narrowing landed on main in this line took minor with feat(…)!: + BREAKING + migration (F2 f606ba2e2, F3 d176b0aab, F4 105902f10, page: delete checkPageComponentViewData once PR #23418 moves the component-props rule onto the save door — ruling 6161173492 item 6, the follow-up for #23357 #23447 a35f7a676) — this changeset matches that shape exactly. service-automation publishes an additive optional parameter on a public method: minor is the right level for a widening. Both packages are published (18.0.0-next.2, not private). @objectstack/rest gains a test file only and @objectstack/spec is untouched — neither owes a changeset. Check Changeset is green on the head.
  • Migration — complete and accurate. Each of the 12 ids' FROM → TO lines matches the registry entry's own summary (filters → filter; object → objectName; notify to/subject/body/url → recipients/title/message/actionUrl, source: {object,id} → sourceObject/sourceId; wait loose duration/timerDuration/signal/signalName/eventType → waitEventConfig.*, block timeoutMs → timerDuration, onTimeout removed; connector connectorId/actionId/input → connectorConfig; flow → flowName on map and subflow; script functionName → function, input → inputs, actionType/template/recipients/variables/script removed; active/template/outputSchema/errorHandling.fallbackNodeId removed; retryDelayMs → backoffMs). Stored rows need nothing — stated. os migrate meta --from 16 named as the source-file counterpart — right.
  • ADR-0087 marker — right category. already-registered: every named id resolves at head and pre-exists this diff (none is added here; retry-policy-converged was retired by F2 on main), and the change retires nothing from the registry — it only stops the door replaying those entries for authored input. check:adr-0087-registration ran green in the head's Lint jobs.
  • Clause-②: yes (narrowing) — right, on the PR body and the changeset alike; it is the arm ruling item 4 prescribes, and the record you are reading is the review that arm owes at CONTRACT_REVIEW_TIER.

③ Boundary flags

  1. Dev deviation 1 / open_questions[0] (predicate scope for a draft save: OCC read only, or the door's baseline with the active-row fallback) — answered: B, as implemented; the confirmation is escalated to the maintainer, where it already sits. The dispatch's needs-decision clause was conditioned on a mechanism assumption the dev measured false (① item 3); ruling items 1 (main clause) and 2 together decide the reading, and A would refuse Studio's first edit of every legacy flow on the main human door against item 2's own words. A ruling's interpretation is the maintainer's: the PR body's 维护者速读 names this reading as the first thing to confirm, and the Tier H approval on docs/adr/** is that confirmation. No re-ruling is needed; reverting to A is deleting the three-line active fallback.
  2. Dev deviation 2 / open_questions[1] (server-stated writes keep the full chain) — answered: B, as implemented; escalated to the maintainer the same way. ① item 5 gives the reading in the code and the governing text; the literal predicate would split the verdict by host and refuse data at rest. The PR body names it as the second thing to confirm at approval.
  3. Dev deviation 4 / open_questions[2] (ruling item 3's MCP door) — answered: option A, a cloud-lane card; escalated to the dispatching PM seat (session_01TQLy3Yq6vF4SDpTXdr3Uwi, the claim 6212892799 holder) to file against the cloud repository's service-ai-studio (create_metadata / update_metadata). Verified: nothing in packages/mcp/src writes metadata, so no pin is constructible in objectstack; this PR cannot carry it, and the ruling's MCP clause stays NOT MEASURED until that card lands. Not a FAIL ground here — the ruling placed the lane in domain:engine with the in-repo doors, and every in-repo door reaches the one saveMetaItem this PR pins.
  4. Dev acceptance note 1 (the spec contract does not name the option) — escalated to the domain:spec seat as a follow-up card. IAutomationService.canonicalizeStoredFlow?(name, definition) (packages/spec/src/contracts/automation-service.ts:853) is unchanged; the protocol passes options structurally, and the one in-repo implementation honours it, but a second implementation that ignores a third argument would lift a retired spelling on a new flow silently — option A's behaviour behind a passing contract. The fix is a one-line optional options?: { includeRetired?: boolean } on the contract (the Clause-②: yes (widening) shape todos? took), spec-lane work outside ruling item 4's lane and outside this PR's claim; not blocking.
  5. Dev deviation 3 (the saveMetaItem's flow canonicalizer throw-fallback is silent — a publish-mode save that skipped canonicalization looks identical to a clean one #4580 warning moved outside the claim's strict lines) — answered: right, see ① item 8. Flow-only, inside protocol.ts, and required so a refused new flow never logs "was saved".
  6. Dev deviation 5 (test:repo not run locally) — answered by CI, partially at read time. Test Core (1/6) and (5/6) concluded success; Test Core (2/6), (3/6), (4/6) and (6/6) were still in_progress at 2026-10-16T12:31Z (see the check-run paragraph below).
  7. Reviewer's own note — a publish-mode edge, consistent with the ruling's letter, no action. A raw PUT /api/v1/meta/flow/:name?mode=publish of a legacy body over a name that holds ONLY a draft row (never published) reads the active address, finds none, and judges the save new — the same answer the OCC read gives at :23517 ("the row the save overwrites"), and the ruling's "at the save's resolution scope". Studio never sends it (publish is a promotion, ① item 4), and os migrate meta --stored rewrites such a draft with source set. Named here so the maintainer sees the predicate's one asymmetric arm; a symmetric publish-reads-draft fallback is not asked for by any ruling item.
  8. Reviewer's own note — the two-round refusal for a bare loose wait key (dev acceptance note 3) is the authoring funnel's own behaviour, pinned as equal; not a finding of this PR.
  9. Dev out_of_scope_findings on scripts/adr-anchors/packages__metadata-protocol__src__protocol.ts.json not naming the predicate — the anchor gate is green; a later anchor is optional hygiene for the engine lane, no carrier needed.

Check-runs on the head (39 at 2026-10-16T12:31Z): 29 success — Auto Label, Build Core, Check Changeset, Check Documentation Links, Check PR Size, Dogfood Regression Gate, Flag docs affected by code changes, Governed Surface Queue Guard, Lint & Repo Gates, Lint · CI release & engine gates, Lint · ESLint, Lint · PM dispatch-gates, Lint · docs & code guards, Lint · repo & tooling gates, Lint · scoped (query-options erasure; slot lookup & corpus; tooling self-tests), the four claim/branch guards, filter, Type Check (consumer gates; debt ledger; source gates; workspace), TypeScript Type Check, Test Core (1/6), Test Core (5/6); 6 skipped by selector — Build Docs, Console Pin Gate, Dogfood Regression Gate matrix, Dogfood Verify CLI, Packed-tarball smoke, Temporal Conformance; 0 failure; 4 not yet concluded — Test Core (2/6), (3/6), (4/6), (6/6). The PASS below judges what was read; the four running shards are the queue's to see green, not a ground of this verdict either way.

Landing. The diff touches docs/adr/** (one row of the register, Tier H), so this record does not lift the four landing acts: the PR stays draft until an authorised approval under PD #14 (the maintainer confirms ③ items 1 and 2 in that same act); the owning engine seat then lands it. No path of this diff is a remainder.

Implemented-by: claude/issue-23775-flow-save-door-new-refusal
Reviewed-by: session_01TQLy3Yq6vF4SDpTXdr3Uwi

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

维护者速读(终稿)· PR #23902 · #23775 裁决 B · domain:engine seat 2 · 2026-10-16T12:36Z

改了什么:通过元数据 API(Studio、REST,以及背后同一个保存口)新建一个 flow 时,如果用了已经退役的旧写法(例如 wait 节点把 duration 写在 config 里),保存口不再悄悄改写后照存,而是返回 422,并指出该写哪个新键,措辞与 os validate 一致。库里已有的 flow 再次保存时照旧自动改写,老数据在 Studio 里照常能改能存。服务端自己搬运库存数据的写入(存量迁移、复制包、非托管导入)也照旧。ADR-0087 的 2026-07-31 附录补了一行日期注记,没有改写原文。

为什么改:执行你 2026-10-16 对 #23775 的裁决 B(6212082860)。以前 AI 通过 API 用旧写法建 flow,保存成功、没有任何提示,错写法会一直留在它的习惯里;现在作者输入一律得到响亮的拒绝和改法,库存数据照旧兼容。

风险与代价(含回滚):

  • 部署里若有客户端通过 API 用旧写法新建 flow,下次会收到 422 和改法;这是裁决要的效果。仓内实测零写入者,Studio 新建 flow 只写新写法。
  • 两处对裁决措辞的落地解读,批准即确认:
    1. 判断「库里有没有这一行」时,草稿保存会看它覆盖的已发布行。裁决原文写「用保存口已有的版本读取(OCC)」,但那次读取发生在改写之后,而且草稿保存时看不到已发布行;照字面做,Studio 第一次编辑任何老 flow 都会被拒,违背裁决第 2 条。现在用的是保存口另外两处已有的同一种读取。
    2. 服务端搬运库存数据的三种写入不算「新建」。否则没有自动化服务的主机会拒绝有自动化服务的主机接受的同一份数据。
  • 一个不对称的边角:直接用 ?mode=publish 覆盖一个只有草稿、从未发布的名字,会按「新建」判;Studio 不走这条路(发布是提升草稿)。
  • 裁决第 3 条要求的 MCP 门测试不在本仓库:写元数据的 MCP 工具在 cloud 仓,本席已转交 cloud 席位。
  • 每次作者保存 flow 多一到两次带索引的读取。
  • 回滚:revert 本 PR 即恢复「全部照抬照收」,不涉及数据迁移。

席位意见:建议批准。契约复审(CONTRACT_REVIEW_TIER)PASS,记录 6215619986,逐条对照裁决六项,唯一偏差是第 1 条括号里的「OCC 读取」,其前提被实测证伪;两处解读都由裁决第 2 条决定。若不同意其中一处,回滚到字面做法只需删三行。

你要做的:在 PR #23902 上批准(Approve)。批准即确认上面两处解读。


席位核对 · ✅ ACCEPT (Tier H, awaiting an authorised approval): PR #23902 at 4a199348b9 · domain:engine seat 2 · session_01TQLy3Yq6vF4SDpTXdr3Uwi · 2026-10-16T12:36Z

Basis: ruling 6212082860 (letter B), claim 6212892799, the dev's report 6215320435, the contract review record 6215619986 (CONTRACT_REVIEW_TIER, PASS on this head, adopted after a tool-use audit of the reviewer's transcript: 52 read-only shell calls and one scratch write), and the seat's own reading of every non-test hunk.

Checklist

Out of scope, one line each:


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Maintainer's word on the PR line · director seat (summon #43 continuation 2, session_01QJC7i981TSs9L1CGJK59Ax) · 2026-10-16T13:18Z

  • The word: 「同意」 in this session's chat on batch 🔗 Broken links detected in documentation #356, which carried this PR as a line (approve or not), on the seat record 6215638408 and the 速读.
  • Approval, read at this write: APPROVED by os-zhuang (review 5547942423, 2026-10-16T12:45Z, on 4a199348b972). The click was the maintainer's; this seat clears the needs-user-decision label in this act (it marks a PR as awaiting the maintainer's review).
  • What lands it from here: the approval confirms the two readings named in 速读终稿 6215638408 (the draft save judges 「stored row」 with the active-row fallback instead of the ruling 口径 1 parenthetical「OCC read」, whose premise the dev measured false; server-stated writes keep the full chain) — both follow ruling 6212082860 item 2 and the director concurs; then CI green and the domain:engine seat 2 readies and arms the PR. The ruling-item-3 MCP pins sit in the cloud repository and were handed to the repo:cloud#1 seat.
  • Merged at this read: true.

Generated by Claude Code

os-elon-musk pushed a commit that referenced this pull request Oct 16, 2026
…be preload; no house-sentence marker in the D3 reason; the save door refuses a new flow (main's #23902), so the changeset says so

Claude-Session: https://claude.ai/code/session_01SgC7iCUX8y2TeJAvz8wcaw
Co-authored-by: Claude <noreply@anthropic.com>
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/l tests tooling

Projects

None yet

3 participants