Repository navigation
Commit cf07168
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
- .changeset
- docs/adr
- packages
- metadata-protocol/src
- rest/src
- services/service-automation/src
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
571 | 571 | | |
572 | 572 | | |
573 | 573 | | |
| 574 | + | |
| 575 | + | |
574 | 576 | | |
575 | 577 | | |
576 | 578 | | |
| |||
0 commit comments