Repository navigation
feat(metadata-protocol)!: the flow save door refuses a retired spelling on a NEW flow; a stored flow keeps the full chain - #23902
Conversation
…tired entries Claude-Session: https://claude.ai/code/session_01TQLy3Yq6vF4SDpTXdr3Uwi Co-authored-by: Claude <noreply@anthropic.com>
…and the stored-row re-save Claude-Session: https://claude.ai/code/session_01TQLy3Yq6vF4SDpTXdr3Uwi Co-authored-by: Claude <noreply@anthropic.com>
…T /meta/flow/:name Claude-Session: https://claude.ai/code/session_01TQLy3Yq6vF4SDpTXdr3Uwi Co-authored-by: Claude <noreply@anthropic.com>
… changeset Claude-Session: https://claude.ai/code/session_01TQLy3Yq6vF4SDpTXdr3Uwi Co-authored-by: Claude <noreply@anthropic.com>
…ow-save-door-new-refusal
…he package's lib has no Array#at) Claude-Session: https://claude.ai/code/session_01TQLy3Yq6vF4SDpTXdr3Uwi Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 2 package(s): 5 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 3 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 14 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # 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
|
Contract reviewServed-tier: 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 ① Derived judgments
② Semver level
③ Boundary flags
Check-runs on the head (39 at 2026-10-16T12:31Z): 29 Landing. The diff touches Implemented-by: VERDICT: PASS |
维护者速读(终稿)· PR #23902 · #23775 裁决 B ·
|
|
Maintainer's word on the PR line · director seat (summon #43 continuation 2,
Generated by Claude Code |
…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>
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 atCONTRACT_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 isflowSaveReplaysRetired:{ includeRetired: false }, and so is the door's own fallback verdict on a host with no automation service (the build: ascriptnode's undeclared config key passesobjectstack validate,compileandregisterFlow, then fails every run — the key half of #21898's class (subflowby 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 textos validateprints (a pin compares the two messages for equality). Draft and publish mode alike.includeRetired: true). A legacy row stays editable, and the save heals it as before.sourceset (the stored-metadata migration),writeFace: 'package-duplicate'andwriteFace: '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 }, defaulttrue.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
catchthat 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/main34b740f2dmerged in, head4a199348b.repo.headAtat the write address, in the block that computesparentVersion) runs after the flow canonicalization. It comes after the schema gate, the runtime authoring gate andensureOverlayIndex, 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.resolveFlowCanonicalizer()→canonicalizeStoredFlowand by theincludeRetired: truefallback, and both now take the predicate's answer. Measured with the card's example, awaitnode's looseconfig.duration(flow-node-wait-event-config-lift): with the window closed the parse refuses it.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.authoredLockFamilyandstoredBodyForCarryForwardalready 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.engine.ts,AutomationEngine, unbound cause) landed before this branch mergedorigin/main. Git merged it intoengine.tsautomatically, with no overlap withcanonicalizeStoredFlow. 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.git grep -nE "config: \{[^}]*(duration|timerDuration|signalName|connectorId|actionId)" f7e836da1 -- examples ':(glob)packages/*/src/**' ':(glob)packages/*/*/src/**' skills content/docsreturns 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) andpackages/spec/src/migrations/registry.ts.template. There are 0 inexamples/**,skills/**,content/docs/**or any package's runtime source. Control: the same pattern onpackages/spec/src/conversions/registry.tsalone 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)
protocol.save-flow-canonicalization.test.ts, engine stub and no-engine host)meta-flow-save-retired-spelling.test.ts, real stack,PUT /api/v1/meta/flow/:name)INVALID_METADATA, located, names the new key, nothing storedos validateissueconfig.durationbeside the block →nodes.1.config.durationnamingwaitEventConfig.timerDuration; the bare loose key →nodes.1.waitEventConfig; draft mode tooGET; Studio's first draft over the stored active rowThe engine option is pinned in
service-automation'scanonicalize-stored-flow.test.ts. No option heals;includeRetired: falserefuses at the written key, or at the block for the bare loose key; a canonical body comes back reference-identical;registerFlowstill registers the legacy spelling.MCP door: no constructible pin in this repository.
packages/mcpregisters no metadata-write tool. The HTTP surface bridges data and action tools only, andmcp-server-runtime.tssays the authoring tool registry is deliberately not bridged there. The tools that write metadata for an AI (create_metadata/update_metadata) belong toservice-ai-studioin 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 throughsaveMetaItemgets 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)flowSaveReplaysRetiredanswerstruefor every save (pre-B)ablation-dist-preflightmarker present (exit 0)includeRetired: options.includeRetired ?? true→trueincludeRetired: falsepins)catchagainLeg 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-protocolwas rebuilt from HEAD andablation-dist-preflight --absentpassed 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-protocolfull 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-automationfull suite after the merge: 186 files, 2507 tests passed. Before the merge, 7 files failed to import@objectstack/service-messaging, whosedist/had not been built. After the rebuild they pass.@objectstack/rest: the new file and three neighbouring flow files, 29/29.typecheck:metadata-protocol(afterlastCallreplacedArray#at),service-automationandrest: exit 0.Gates
Local: the 76 families
dispatch-gates.mjs --commandsderived 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-loadsanswers PREREQUISITE NOT MET because it needs the whole tree built, so it is NOT MEASURED locally; CI'sBuild Coreruns it. Everything else that CI runs is cited from this PR's check-runs in the report.Acceptance notes
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.scripts/adr-anchors/packages__metadata-protocol__src__protocol.ts.jsondoes not mention it, because that file was outside the claim's surface.config.durationis refused atwaitEventConfig(the node's own rule names the block andtimerDuration). The loose key's own refusal appears only after the block exists. Authors meet the authoring funnel's behaviour in two rounds, here and atos validatealike. This is an observation and was not filed.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的口径一致。风险与代价(含回滚):
席位意见:
你要做的:确认上面「草稿看已发布行」和「服务端搬运写入照旧」两处解读,然后批准(Tier H,ADR 一行)。MCP 门的同款测试在 cloud 仓,需另行安排。
Generated by Claude Code