Repository navigation
feat(metadata-protocol)!: a flow re-save that newly introduces a retired spelling is refused like a new flow's - #23986
Conversation
…inst the stored row (#23903) Claude-Session: https://claude.ai/code/session_01TQLy3Yq6vF4SDpTXdr3Uwi Co-authored-by: Claude <noreply@anthropic.com>
…23903) Claude-Session: https://claude.ai/code/session_01TQLy3Yq6vF4SDpTXdr3Uwi Co-authored-by: Claude <noreply@anthropic.com>
… re-save narrowing (#23903) Claude-Session: https://claude.ai/code/session_01TQLy3Yq6vF4SDpTXdr3Uwi Co-authored-by: Claude <noreply@anthropic.com>
…g template literal (#23903) Claude-Session: https://claude.ai/code/session_01TQLy3Yq6vF4SDpTXdr3Uwi Co-authored-by: Claude <noreply@anthropic.com>
…ow-save-reintroduced-retired
📓 Docs Drift CheckThis PR changes 1 package(s): 5 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 11 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 b4e7f2f556e23ed10da396b69dc72f0afb83fb71 && git checkout b4e7f2f556e23ed10da396b69dc72f0afb83fb71
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 022c6e799e88ae192e124c80e1cc343bda54d7bf c8e2d674a861f6e74707e5424c2b4524f635d7fd && git checkout -B drift-repro 022c6e799e88ae192e124c80e1cc343bda54d7bf && git merge --no-ff c8e2d674a861f6e74707e5424c2b4524f635d7fd
node scripts/docs-audit/affected-docs.mjs --json 022c6e799e88ae192e124c80e1cc343bda54d7bf
|
Contract reviewServed-tier: Inputs: card #23903 (body and all six comments, the director delta 6215813374, the ruling 6216220018, triage's unlock 6217547376, the claim 6218148190, the dev report 6219774643), the prior ruling 6212082860 on #23775, PR #23986's body, file list and net diff against ① Derived judgmentsThe diff changes one thing a caller can observe: the accept set of the flow save door (
② Semver level
③ Boundary flagsEvery dev flag and both
Implemented-by: VERDICT: PASS |
维护者速读(终稿)· PR #23986 · #23903 裁决 B′ ·
|
|
CI update to the 席位核对 6220257669 · Generated by Claude Code |
|
Landing state after the approval ·
Generated by Claude Code |
|
Correction to 6227374639 · Generated by Claude Code |
Fixes #23903
Clause-②: yes (narrowing)
Ruling-ref: 6216220018 (letter B′ (i) yes · C (ii) no). Claim: 6218148190. Session:
session_01TQLy3Yq6vF4SDpTXdr3Uwi. Extends theflowSaveReplaysRetiredpredicate PR #23902 added (cf071687f1).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 5) belongs to the seat.What changes
flowSaveReplaysRetired(packages/metadata-protocol/src/protocol.ts), the predicatesaveMetaItem's flow branch asks before it canonicalizes, now compares conversion hits when a stored row exists:false, so the engine's canonicalizer and the door's own fallback verdict both run without the retired entries, exactly as for a new flow. The retired spelling meets the flow parse's refusal:422 INVALID_METADATA, an issue located at the key, the textos validateprints. Draft and publish mode alike.false; the server-stated writes (sourceset,writeFace: 'package-duplicate'and'package-unmanaged-import') answertruebefore any read; the engine's rehydration and the read path are untouched. Ruling item 3 (C) is not taken:convertStoredItemstill skipsflow.The whole body is judged in the authoring posture (ruling item 2: "judged with
includeRetired: false"), so the 422 also lists the retired sites the stored row already carries. Correcting the added site alone is enough to save; the full chain then heals the rest.A hit is one firing of a retired entry: its
conversionId, the notice'sfrom(the spelling it rewrote), and its site, which is the notice'spathwith every array element on it that carries anidnamed[#id]instead of by its index. "Retired" isretiredFromLoadPathon the entry inALL_CONVERSIONS.The rows that may carry a hit are PR #23902's baseline (the row the save overwrites,
repo.headAt), and for a draft save also the active row the draft overlays. PR #23902 reads that row only when no draft row exists; this PR also reads it when the draft row does not carry a request hit. See "Readings the approval decides".The edit stays inside the claim's surface: the predicate, two comment paragraphs in the flow branch that calls it, and one import (
ALL_CONVERSIONSfrom@objectstack/spec).packages/services/service-automation/src/engine.tsis not touched.The hit report, measured (the dispatch's assumptions 1 to 5)
Tree: base
6d1660dc3,origin/main0b6956490merged in, headc8e2d674a.canonicalizeStoredFlow(name, body, { includeRetired: true })returnsnoticeswith keyscode, conversionId, surface, toMajor, retiresIn, from, to, path, message; the spec'sapplyConversionsToFlow(body, { includeRetired: true, onNotice })emits the same notices. Measured on one body with four retired sites (awaitnode's looseconfig.duration, twoconfig.filtersof which one sits inside a loop region, anderrorHandling.retryDelayMs): the sameconversionId,fromandpathon both, for exampleflow-node-crud-filter-alias,filters,flows[0].nodes[3].config.body.nodes[0].config.filter. Neither carries a retired flag;ALL_CONVERSIONSanswers it by id. The predicate scans with the spec's pass on BOTH hosts (the full chain minusFLOW_FALLBACK_VERDICT_NOT_REPLAYED, which equals the engine'sCONVERSIONS_NOT_REPLAYED_AT_REHYDRATION), so its answer cannot depend on the host (build: ascriptnode's undeclared config key passesobjectstack validate,compileandregisterFlow, then fails every run — the key half of #21898's class (subflowby reading) #21982), and a canonicalizer throw cannot hide a hit.repo.headAt(...).head.bodyis the stored body (rowToItem,storedRowBody); no new repository call. The one added read is the active row for a draft save whose draft row exists and does not carry a request hit: the addressheadAt(ref, { state: 'active', packageId: target.packageId, member }), the one PR feat(metadata-protocol)!: the flow save door refuses a retired spelling on a NEW flow; a stored flow keeps the full chain #23902 reads for its fallback.pathis index-based (flows[0].nodes[2].config.filter). An entry id alone cannot see a node added with the spelling another node already carries (ablation leg 2: 9 protocol pins and 3 REST pins red), and a raw index path reads a node inserted in front of a carried one as new (leg 3: 2 pins red). The finest identity the report carries is entry,fromandpath; the predicate resolves each indexed element on the path to itsidon the converted body. No engine change, no invented location.flow-node-http-callout-rename, is live, and it is the one entry PR feat(spec)!: reserve the retired callout node types http_request, http_call and webhook; flow-node-http-callout-rename leaves the load path (F5-http of #23543) #23954 (F5-http) retires (its registry diff is one hunk, that entry). So the pins use two node-level spellings F5-http does not move: adelete_recordnode'sconfig.filters(flow-node-crud-filter-alias, refused namingfilter) and awaitnode's looseconfig.durationbeside its block (flow-node-wait-event-config-lift, refused namingwaitEventConfig.timerDuration, the spelling the REST file already used). When F5-http lands, its three names fall under this predicate with no change here, because it readsretiredFromLoadPath.packages/runtime/src/domains/automation.ts) saves an authored body with nosourceorwriteFace, so it meets the same predicate, and it restores the engine registration on a refusal. The organization-ownership ceremony's conflict re-save sends the body the row was just written with, so its hits are carried by construction.publishMetaItempromotes a draft and never reaches the predicate. No metadata-write MCP tool is registered in this repository (PR feat(metadata-protocol)!: the flow save door refuses a retired spelling on a NEW flow; a stored flow keeps the full chain #23902's reading).Readings the approval decides
63d00da,DraftVersionGuard.saveholds only the returned version, and the buffer is replaced only on a conflict reload. On an engine host the first draft save heals the draft row, so judged against the draft row alone, Studio's SECOND autosave of a legacy flow would be refused, against ruling [Decision] The metadata save door lifts a retired flow spelling on a NEW flow and stores it, where every other authoring door refuses it — refuse on a new flow, or keep the door a rehydration seam? #23775's "a legacy row stays editable in Studio". The pin "Studio's draft autosaves" holds this; ablation leg 4 turns it red on the engine host.saved, row stored asfilter, second422 INVALID_METADATAatnodes.0.config.filters. No automation service: firstsaved, row stored asfilters, secondsaved. The predicate gives one answer for one request and one stored row; the two hosts store different rows (the engine heals at rest, the fallback stores the raw body), and the ruling's letter reads the stored row. Studio publishes by promotion and reloads, so it never meets this. Raised as an open question in the report on the card.Pins (ruling item 4)
protocol.save-flow-canonicalization.test.ts)meta-flow-save-retired-spelling.test.ts, real stack, no automation service)INVALID_METADATAat the new node's key, naming the new key; the stored row untouchednodes.1.config.duration,waitEventConfig.timerDuration)nodes.1.config.filters,filter)nodes.1.config.durationandnodes.2.config.duration, the new one namingwaitEventConfig.timerDuration)nodes.0.config.filters)GETthenPUTof the item as served: 200includeRetired: falseon an introduced hit,trueon a carried oneThe engine host is the spec's own pass behind the canonicalizer's option, so a carried re-save is stored canonical as
AutomationEngine.canonicalizeStoredFlowstores it. With the window closed it hands the retired spelling back and the gate refuses it; the real engine's strict parse throws first, and the door's fallback verdict refuses the same body with the same issues.Ablation (mutation through
scripts/ablation-replace.mjs, wrap mode; restore proven by blob hash equal to HEAD and an emptygit diff HEAD)ablation-dist-preflightmarker present, exit 0f40fd1543changed the line it mutates: 2 failed both times.siteOfunused). It was re-run with a replacement that keeps the locals used.protocol.tsblobcd5449d4e;f40fd1543touches none of their anchors.metadata-protocolrebuilt from HEAD andablation-dist-preflight --absentexit 0 for both markers (working tree clean); REST 10/10, protocol 51/51.Tests
@objectstack/metadata-protocol, full suite atc8e2d674a: 242 files passed, 3 skipped; 20629 tests passed, 19 skipped (VERDICT command-exit 0). An earlier full run at0cef5ed02had one failure:protocol.bracketed-refusal-opener-absence.test.tsread the new line's template literal opening with an interpolated bracket as a refusal tag.f40fd1543spells that branch with concatenation; the two files then passed 58/58.protocol.save-flow-canonicalization.test.ts: 51/51 (34 before, 17 new).@objectstack/restsrc/meta-flow-save-retired-spelling.test.tsatc8e2d674a,metadata-protocolrebuilt: 10/10 (6 before, 4 new).typecheck:metadata-protocolexit 0 (the test file is in its tsc program,--listFilescount 1);restexit 0, itscheck:test-typecheckcompiling the test layer undertsconfig.test.json.pnpm lintandtest:repobelong to CI.Gates
dispatch-gates.mjs --commandsatc8e2d674a(merge base0b6956490): 74 commands, byte-identical to the PM's list at6d1660dc3. All 74 ran, every exit captured before any pipe: 74 exit 0.check:dual-build-cjs-loadsfirst exited 3 (PREREQUISITE NOT MET, unbuilt workspace) and was re-run oncecheck:type-check-debthad built the closure: exit 0.--ranreconciliation: 74 derived, 74 run, 0 NOT-MEASURED, 0 UNRUN, a derived zero.check-changeset-fixedexit 0,check:authz-resolverexit 0,check:error-code-casingexit 0.Acceptance notes
includeRetired: false. The changeset says that correcting the added site alone is enough.idis renamed while it keeps a retired spelling reads as a new node and is refused.dispatch-gates.mjsprints that a card which only narrows isClause-②: no; AGENTS.md's grammar and the ruling sayyes (narrowing). This PR follows the ruling, as PR feat(metadata-protocol)!: the flow save door refuses a retired spelling on a NEW flow; a stored flow keeps the full chain #23902 did. Noted, not filed.维护者速读(草稿)
改了什么
通过元数据接口(Studio 保存、REST
/meta、运行时的 flow 接口)再次保存一个已存在的 flow 时,如果请求里出现了库里原行没有的旧拼写,比如往旧 flow 里新加一个节点仍用旧写法,或者在原来已是新写法的节点上改回旧写法,现在会像新建 flow 一样被拒收:返回 422,指出具体位置和应改成的新写法,与os validate说的一样。原行本来就带的旧拼写照旧在保存时自愈,原样保存读到的旧 flow 不受影响。为什么改
执行裁决 6216220018(B′):再存时新引入的旧拼写是作者输入,应与新建 flow 一样拒收。#23902 只拦新建 flow,往旧 flow 里新加的旧写法仍被悄悄改写,写入方(尤其是 AI)学不到正确写法。
风险与代价(含回滚)
席位意见
你要做的
审阅并批准(Tier H:ADR-0087 加了一条日期注记)。请顺带确认上面两条解读:草稿保存把已发布行算作原行;发布模式下旧副本在两类主机上的差异按裁决字面处理。
Generated by Claude Code