Skip to content

Commit cf07168

Browse files
feat(metadata-protocol)!: the flow save door refuses a retired spelling on a NEW flow; a stored flow keeps the full chain (#23902)
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 #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 #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 #23836's view-node functions. 5. **No in-repo producer.** `git grep -nE "config: \{[^}]*(duration|timerDuration|signalName|connectorId|actionId)" f7e836d -- 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 #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](https://claude.ai/code/session_01TQLy3Yq6vF4SDpTXdr3Uwi)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent b9dbc3d commit cf07168

7 files changed

Lines changed: 692 additions & 57 deletions

File tree

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
---
2+
'@objectstack/metadata-protocol': minor
3+
'@objectstack/service-automation': minor
4+
---
5+
6+
feat(metadata-protocol)!: the metadata save door refuses a retired flow spelling on a NEW flow, and a stored flow keeps re-saving
7+
8+
Clause-②: yes (narrowing)
9+
10+
**BREAKING (API save door):** a flow saved through `saveMetaItem` (`PUT /api/v1/meta/flow/:name`, the runtime dispatcher, and every caller of the protocol) with no stored row of that name at the save's address is now judged as authored input. It is canonicalized without the ADR-0087 D2 conversions retired from the authoring funnel, so a spelling the loader stopped lifting at 18 is no longer rewritten and stored: it meets the flow parse's refusal, `422 INVALID_METADATA`, with an issue located at the key and the same prescription `os validate` and `os build` print. Draft and publish mode alike. Before, the door lifted the old spelling, stored the new one and said nothing, so the client that wrote it (an AI writing flows through MCP or REST above all) was never told and wrote it again.
11+
12+
What counts as a stored row: the row the save overwrites, at its state and package, and for a draft save with no draft yet the active row the draft overlays, so Studio's first draft edit of a stored flow is a re-save.
13+
14+
Not changed:
15+
16+
- A re-save of a stored flow keeps replaying the full chain, retired entries included, so a legacy row stays editable and is healed by the save, exactly as before.
17+
- The server's own writes of bodies at rest keep the full chain: the stored-metadata migration (`os migrate meta --stored`), a package duplicate and an unmanaged package import.
18+
- The automation engine's flow rehydration (`registerFlow`) and the read path, which still serves a stored flow verbatim.
19+
20+
Migration, for a client that creates flows through the API:
21+
22+
- `wait` node loose `config.duration` / `config.timerDuration` → `waitEventConfig.timerDuration`; `config.signal` / `config.signalName` → `waitEventConfig.signalName`; `config.eventType` → `waitEventConfig.eventType`. On the block itself, `timeoutMs` → `timerDuration` and `onTimeout` is removed.
23+
- `connector_action` node loose `config.connectorId` / `config.actionId` / `config.input` → its `connectorConfig` block.
24+
- CRUD node `config.filters` → `config.filter`; `config.object` → `config.objectName`.
25+
- `map` and `subflow` node `config.flow` → `config.flowName`.
26+
- `notify` node `config.to` → `recipients`, `subject` → `title`, `body` → `message`, `url` → `actionUrl`, `source: { object, id }` → `sourceObject` / `sourceId`.
27+
- `script` node `config.functionName` → `function`, `input` → `inputs`; `actionType` / `template` / `recipients` / `variables` / `script` are removed.
28+
- `errorHandling.retryDelayMs` and a `try_catch` node's `retry.retryDelayMs` → `backoffMs`; the flow keys `active` / `template`, a node's `outputSchema` and `errorHandling.fallbackNodeId` are removed.
29+
30+
Each refusal names the key to write. `os migrate meta --from 16` lists the same edits for source files. Rows already stored need nothing.
31+
32+
`@objectstack/service-automation`: `AutomationEngine.canonicalizeStoredFlow(name, definition, options?)` takes `{ includeRetired?: boolean }`. Omitted, it is `true` (the full chain, as every caller holding a body at rest wants). `false` is the authoring posture the save door asks for. `registerFlow` does not take the option.
33+
34+
<!-- adr-0087: not-required (already-registered flow-node-crud-filter-alias, flow-node-crud-object-alias, flow-node-notify-config-aliases, flow-node-wait-event-config-lift, flow-node-map-flow-alias, flow-node-subflow-flow-alias, flow-node-connector-config-lift, flow-node-script-config-aliases, flow-inert-keys-removed, flow-node-wait-timeout-keys-removed, flow-node-script-branch-keys-removed, retry-policy-converged) these D2 entries already record every spelling the save door now refuses on a new flow; this change only stops the door replaying them for authored input, and their own migrations are the ones above -->

‎docs/adr/0087-metadata-protocol-upgrade-contract.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -571,6 +571,8 @@ diverged (#3903). This addendum extends the contract to data at rest:
571571
(availability over purity for data at rest; the same verdict reaches Studio
572572
as `_diagnostics` on every read).
573573

574+
> **2026-10-16 note** (#23775, ruling B) — the flow save door draws this line by one predicate: `saveMetaItem` canonicalizes a flow with **no stored row at the save's address** (the row the save overwrites, or for a draft with none yet the active row it overlays) **without** retired entries, so a retired spelling meets the parse refusal (`422 INVALID_METADATA`, located, the text `os validate` prints), while a re-save of a stored row — and the server's own writes of bodies at rest — keep the full chain.
575+
574576
## Addendum (2026-08-01) — the stored chain gets a finish line (#4327)
575577

576578
The addendum above makes a legacy row read canonical *forever*, which is the

0 commit comments

Comments
 (0)