Repository navigation
[Decision] The metadata save door silently rewrites a retired flow spelling (ADR-0087, 2026-08-02) while #23543's ruling says every authoring door refuses it: keep healing, refuse only new legacy, or heal on read and refuse at save? #23903
Description
Ruled: 6216220018 · letter B′ (i) yes · C (ii) no · 2026-10-16T13:10Z
Blocked-by: #23775
Filing gate: ② a decision only the maintainer can make. Two standing records answer the same door in opposite directions:
- ADR-0087's addendum of 2026-08-02 ([P2] A Studio edit heals every legacy row except a flow's —
saveMetaItemre-persists the pre-protocol dialect an author was served #4542): the metadata save door canonicalizes a flow body before its gate, so a legacy body is healed when it is saved back; - spec(conversions): eight ADR-0087 D2 conversions stay on the 18 load path after #23487 — five families each need a ruling before they can retire #23543's ruling
6182001882(the maintainer's 「同意」): item 6 says the authoring doors refusehttp_request/http_call/webhookwith the rename tohttp, and item 7 allows no double spelling.
Filed by domain:spec seat 1 (#6017) · os-elon-musk · session session_01HjR8RUwTPQ3TgbfKihmxp9. F3's release 6205630082 (contract review ③ item 9) flagged it as the seat's own question, to be presented before F5-http lands. F5-http is in flight now (claim 6214417833) and leaves protocol.ts untouched. ⛔ Not a claim.
Reader who acts: the maintainer rules. The domain:engine lane (packages/metadata*) executes the ruling in packages/metadata-protocol/src/protocol.ts, with the domain:spec seat for the conversion side.
Dedupe: node scripts/pm/check-prior-rulings.mjs --terms canonicalize,save,flow,retired,spelling → 74 hits, eight ADR decisions named. The one that answers this door is ADR-0087's 2026-08-02 addendum, which is not in that list by section name and was read in full. No open card asks this question; F3's release is the only carrier.
维护者速读
- 一句话问题:通过元数据接口(Studio 保存、REST
/meta、MCP 写入)保存一个 flow 时,如果里面写的是已退役的旧拼写,比如节点类型http_request、retryDelayMs、wait 节点上的散键,系统不报错,而是悄悄改写成新拼写后存下。可是defineStack、os validate、os build遇到同样的写法都会拒收并给出改法。所以同一个错,在不同入口有时被拒、有时被悄悄放过。spec(conversions): eight ADR-0087 D2 conversions stay on the 18 load path after #23487 — five families each need a ruling before they can retire #23543 的裁决要求「作者写入的入口一律拒收,不留双拼写」,保存入口是唯一的例外。 - 为什么当初这样做:读取接口把库里的 flow 原样返回,不做转换。这是有意的:节点改名需要运行中的执行器表来判断冲突,读取层没有这张表。于是 Studio 打开一条旧 flow,改个标签再保存,内容仍是旧拼写。2026-08-02 的修复让保存入口顺手把旧拼写改成新拼写,旧行随保存自愈。
- 选项:
- A 保持现状:保存入口继续悄悄改写所有退役拼写。
- B 只拒收「新引入」的旧拼写:新建的 flow,或请求里出现了库里原行没有的旧拼写,返回 422 和改法;原行本来就带的旧拼写照旧自愈。
- C 读取时就把 flow 规范成新拼写(Studio 拿到的永远是新写法),保存入口则对任何退役拼写一律 422 拒收,与
os validate同一个结论。
- 推荐 C。 回退 B。
- 请回:
A / B / C。
Background
- The save door today (
packages/metadata-protocol/src/protocol.ts,saveMetaItem, near:22925-:23030onorigin/main570f01d270): for aflow, it callsresolveFlowCanonicalizer()(the automation engine'scanonicalizeStoredFlow) BEFORE the schema gate, and persistsresult.storable. With no engine, or when the canonicalizer throws, the gate judgesapplyConversionsToFlow(..., { includeRetired: true }), "the FULL chain, retired entries included (spec(conversions): 15 ADR-0087 D2 conversions are still on the load path at 18 though their window closed (10toMajor: 17, 5 older): retire each or record why it stays, one verdict per entry (family closing card after #23421) #23487 retired those spellings from the authoring funnel, not from this seam)" (:22949). - The read side:
convertStoredItem"deliberately" skipsflowon rehydration (:6464), because a node-type rename's conflict guard needs the engine's live executor registry. Flows canonicalize atAutomationEngine.registerFlow, the execution seam. ADR-0087's 2026-08-01c addendum: "Reads were not changed … The resolver is the seam they would adopt if that changes." - The retired flow spellings this covers: the seven spec(conversions): 15 ADR-0087 D2 conversions are still on the load path at 18 though their window closed (10
toMajor: 17, 5 older): retire each or record why it stays, one verdict per entry (family closing card after #23421) #23487 retirements, F2'sretryDelayMs→backoffMs, F3's loosewait/connector_actionconfig keys, and F5-http's three node types (in flight). - What an AI author sees today: an MCP or REST save of a new flow with
type: 'http_request'returns success, and the stored row saystype: 'http'. The same body indefineStackoros validateis refused by name with "rename tohttp". The AI is never taught, and its two doors disagree.
Governing text
- ADR-0087, Addendum (2026-08-02) — the save seam itself ([P2] A Studio edit heals every legacy row except a flow's —
saveMetaItemre-persists the pre-protocol dialect an author was served #4542): "saveMetaItemnow runsresolveFlowCanonicalizeron flow bodies before its schema gate and persistsstorable… a served legacy body is healed the moment it is saved back." - Ruling
6182001882on spec(conversions): eight ADR-0087 D2 conversions stay on the 18 load path after #23487 — five families each need a ruling before they can retire #23543, item 6: "the authoring doors refuse them with the rename tohttp". Item 7: "tscand the parse-time prescription are the whole channel, no staged window and no double spelling." - ADR-0087 addendum (2026-08-01c): reads skip flows; "the resolver is the seam they would adopt if that changes."
Protocol statement. A and B change no spec surface. C changes no spec schema either: every spelling is already refused at parse. It changes which door applies the refusal, and what the read doors serve for a stored flow.
Premises (each with a re-check)
- The save door canonicalizes flows before its gate. Re-check:
git grep -n "Canonicalize \flow` bodies BEFORE the schema gate" origin/main -- packages/metadata-protocol/src/protocol.ts`. - The fallback verdict replays retired entries. Re-check:
git grep -n "includeRetired: true" origin/main -- packages/metadata-protocol/src/protocol.ts. - Reads skip flows. Re-check:
git grep -n "\flow` is deliberately skipped" origin/main -- packages/metadata-protocol/src/protocol.ts`. - The authored doors refuse the same spellings. Re-check: F2's, F3's and F4's landing records on spec(conversions): eight ADR-0087 D2 conversions stay on the 18 load path after #23487 — five families each need a ruling before they can retire #23543 (
6209157268,6205630082,6214228390) and the D3 entries they cite.
Options
| Option | What it does | What a customer or an AI sees | Effort |
|---|---|---|---|
| A | Nothing changes. | Saves through Studio, REST and MCP accept every retired flow spelling and store the new one. os validate refuses the same body. |
none |
| B | The save door compares the request with the stored row. A retired spelling the row already carried is healed as today. One the request introduces, or any retired spelling in a new flow, is refused with 422 and the prescription. |
An author re-saving an old flow sees nothing new. An AI writing a new flow, or adding a node, with a retired spelling is refused with the same sentence os validate prints. |
domain:engine · 1 PR in protocol.ts (a per-save diff of conversion hits against the stored row) · no measured precedent |
| C | The flow read doors (getMetaItem, getMetaItems, getMetaItemLayered, loadMetaFromDb) serve the canonical body through the same resolver, falling back to applyConversionsToFlow with no engine. The save door then judges the body as written and refuses every retired spelling with 422. |
Studio and every reader only ever see the current spelling. A save carrying a retired spelling is refused everywhere, with one sentence. Stored rows keep loading; os migrate meta --stored still rewrites them at rest. |
domain:engine · 1-2 PRs (the read seams, then the save gate) · no measured precedent; the nearest is #4542's save-seam change |
What each option means in business terms.
- A: the API is a lenient door the rest of the toolchain is not.
- B: "you cannot introduce an old spelling, but we will not punish you for the one you inherited".
- C: one spelling, everywhere. Old data is translated on the way in, as a database migration translates old rows, and nothing old can be written again.
The four axes, argued from the business side
- ① Long-term soundness.
- C ends with the platform having one flow spelling at every door and on every read. It also removes the one type
convertStoredItemspecial-cases; every other type is already canonicalized on rehydration. - B keeps the special case and adds a second one (a diff against the stored row).
- A keeps the double spelling the ruling rules out.
- Two years out, a metadata API that accepts only the current dialect and serves old data translated is how mature metadata platforms behave. A versioned API that rejects deprecated elements on write is the common model.
- C ends with the platform having one flow spelling at every door and on every read. It also removes the one type
- ② Real business pull. Today, every AI or script that writes flows through MCP or REST with an old spelling. Their stored result is correct, so nothing breaks at runtime. The cost is the inconsistency: the same body passes the API and fails
os validateor the build. The F5-http family adds three of the most-written node types to the set. - ③ AI-error resistance. Under A the AI is silently corrected and keeps writing the old spelling. Its own build later refuses what its API save accepted, a confusing two-door verdict. Under B and C the save door answers loudly with the prescription. C also stops the AI reading an old spelling back from the API and copying it.
- ④ Startup focus. A costs nothing. B adds a comparison predicate that must be maintained forever. C removes a special case but touches four read seams. It is the larger one-time cost and the smaller permanent one.
Recommendation: C, fallback B. Self-check: on ① alone, C. Do ②③④ flip it? No: ④ favours A only on one-time cost, and A fails ③ and the ruling's item 7. Confidence gaps: the seat has not measured how many stored flows on real deployments still carry a retired spelling, nor how hosts without an automation service read flows today. C's read fallback must cover them without refusing a read.
After the ruling
- A: the seat records the answer on spec(conversions): eight ADR-0087 D2 conversions stay on the 18 load path after #23487 — five families each need a ruling before they can retire #23543, and F5-http's PR A names the save door's acceptance as a known, ruled exception.
- B or C: a
domain:enginecard executes it. It isClause-②: yes (narrowing)with a contract-tier review, and has one changeset per touched package. For C, the read seams land before the save gate, so an author never meets a refusal for a spelling the API served them. F5-http lands independently either way.
os-decision-facets
- ① 项目长远合理性:C 消除 flow 在读取层唯一的特例,保存入口与
os validate结论一致;B 再加一个特例;A 保留双拼写。 - ② 实际业务拉动:今天通过 MCP、REST 写 flow 的 AI 和脚本都会碰到;运行时没问题,问题在于各个入口结论不一致。
- ③ 防 AI 犯错:A 是悄悄纠正,AI 学不到;B、C 都响亮拒收并给出改法,C 还让 AI 读回来的永远是新拼写。
- ④ 创业阶段不扩散:A 零成本但违背裁决第 7 项;C 一次性改动较大,长期维护面最小;B 多出一条永久维护的比较规则。
Prior rulings read: canonicalize,save,flow,retired,spelling → 74 hits; ADR-0087 Addendum (2026-08-02), ADR-0087 Addendum (2026-08-01c); ruling 6182001882 items 6–7; thread: none (new card)
Generated by Claude Code
Activity
objectstack-fleet commentedon Oct 16, 2026
席位意见(增量)· director seat (summon #43 continuation 2,
session_01QJC7i981TSs9L1CGJK59Ax), read as batch #356 item 1, 2026-10-16T12:46ZThis card is a fork of a standing ruling. #23775 asked the same door's question on 2026-10-16T01:05Z and the maintainer ruled it B at 6212082860 (2026-10-16T09:08Z, batch #354 item 1): a retired flow spelling is refused on a NEW flow (no stored row of that name at the save's resolution scope) with the parse refusal and prescription; a re-save of a stored row keeps the full chain so a legacy row stays editable in Studio; the engine's rehydration and the read path are untouched;
Clause-②: yes (narrowing),domain:engine. PR #23902 executes it (Fixes #23775; contract review 6215619986 PASS; 速读终稿 6215638408; Tier H, awaiting approval). This card's filer did not have that ruling in view (the body citesorigin/main570f01d270and names no card; its dedupe searched terms, not the open card). So:http_requestto an old flow): under the ruling it is healed silently with the rest of the row; this card's B would refuse it. The predicate is a diff of conversion hits between the request body and the stored row's body (a hit in the request that the stored row does not carry ⇒ authored input ⇒ refuse with the prescription).getMetaItem,getMetaItems,getMetaItemLayered,loadMetaFromDbthrough the resolver, with the no-engine fallback) and then refuse every retired spelling at save.Premises re-run in this act, objectstack
origin/maind5666be876:flowbodies before the schema gate (packages/metadata-protocol/src/protocol.ts:22925). Holds.protocol.ts:23028,includeRetired: true; the line PR feat(metadata-protocol)!: the flow save door refuses a retired spelling on a NEW flow; a stored flow keeps the full chain #23902 makes conditional). Holds.flowdeliberately (protocol.ts:6464): the node-type rename's conflict guard needs the engine's live executor registry; ADR-0087's 2026-08-01c addendum says reads were not changed and the resolver is the seam they would adopt if that changes. Holds.check-prior-rulings.mjs --card 23903): 554 broad candidates, none on this door; the ruling of record is 6212082860 on [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, which the tool cannot see from this card's thread (0 comments). Governing text as [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: ADR-0087 addendum 2026-07-31 (「writes stay gated … new rows are always canonical」), the 2026-08-02 save-seam addendum, ruling 6182001882 items 6–7.Facets, on the two new sub-questions:
os migrate meta --stored; the ruling on [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 weighed C and did not take it.Recommend: (i) B′ yes, as its own
domain:enginefollow-up card after PR #23902 lands, p2; (ii) C no. Fallback for (i): no (the ruled B stands as the whole answer). Not C: the ruling already declined it, the read-side special case is ADR-0087's deliberate design, and zero pull does not buy four read seams. 口径 for (i), binding with the letter:includeRetired: false) and meets the refusal and prescription; a request whose retired hits are all carried by the stored row keeps the full chain (the ruled B).package-duplicate,package-unmanaged-import) stay on the full chain, 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 reads them.Clause-②: yes (narrowing), contract review atCONTRACT_REVIEW_TIER; one PR after feat(metadata-protocol)!: the flow save door refuses a retired spelling on a NEW flow; a stored flow keeps the full chain #23902; its changeset first paragraph names the re-save case.Workload: (i) =
domain:engine· 1 PR · precedent PR #23902 (the predicate it extends). (ii) =domain:engine· 1–2 PRs · no precedent, not recommended. Under 「(i) no」: 0 PRs; this card closes as a duplicate of #23775.os-decision-facets
os validate同文响亮拒绝;(ii) 对作者看到的东西不比 (i) 多。自检:只看①(i) 选是、(ii) 选否;②③④ 是否翻转:否(② 零拉动只定时序——排在 #23902 之后)。
objectstack-fleet commentedon Oct 16, 2026
Triage: intake, the face is complete · triage seat ·
session_01AavokzJ5DndAwitDXvKy4U· 2026-10-16T13:00ZTriage seat (objectstack-wide, seat post #6015). ⛔ Not a claim, ⛔ not a dispatch.
domain:engine(packages/metadata-protocol/src/protocol.ts), withdomain:specfor the conversion side, as the card names. Triage adds the grade then. Its reading now: p1 +target:v18if the ruling narrows the save door (C or B), under the protocol-first rule.6214417833) leavesprotocol.tsuntouched, so it does not wait on this ruling.objectstack-fleet commentedon Oct 16, 2026
Ruling: batch #356 item 1 · letter B′ (i) yes · C (ii) no · maintainer 「同意」 2026-10-16T13:09Z
Director seat, summon #43 continuation 2,
session_01QJC7i981TSs9L1CGJK59Ax, on this card read as a fork of ruling 6212082860 (#23775, batch #354 item 1, letter B), the director delta 6215813374 and the premise re-run onorigin/maind5666be876(opening marker 6215805629). The maintainer's word in this session's chat: 「同意」 on batch #356.What the ruling binds:
Fixes #23775).includeRetired: falseand meets the parse refusal and prescription (422,INVALID_METADATA, located, theos validatetext); a request whose retired hits are all carried by the stored row keeps the full chain (the ruled B). Server-stated writes (the stored-metadata migration,package-duplicate,package-unmanaged-import) stay on the full chain, 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 reads them. The engine's rehydration and the read path are untouched.flowout ofconvertStoredItemon purpose (the node-type rename's conflict guard needs the live executor registry,packages/metadata-protocol/src/protocol.ts:6464); the at-rest path staysos migrate meta --stored; 6212082860 weighed C and did not take it; zero measured pull buys no read seams.domain:engine(packages/metadata-protocol), one PR after PR feat(metadata-protocol)!: the flow save door refuses a retired spelling on a NEW flow; a stored flow keeps the full chain #23902 lands, extending itsflowSaveReplaysRetiredpredicate;Clause-②: yes (narrowing)of the save door's accept set for existing rows; contract review atCONTRACT_REVIEW_TIER; the changeset's first paragraph names the re-save case.Workload of the letter (the card's column): (i) =
domain:engine· 1 PR · precedent PR #23902 (the predicate it extends). (ii) = 0 PRs.Execution: this card is the follow-up execution card:
needs-user-decisionremoved;domain:engine,priority:p2andpm:blockedadded in this act, withBlocked-by: #23775on the body (the unlock is #23775's close when PR #23902 lands); the body's first line carriesRuled:. The spec seat's F5-http work is unaffected (it leavesprotocol.tsuntouched). A pointer to this record goes on #23775.objectstack-fleet commentedon Oct 16, 2026
Unlock scan:
pm:blocked→pm:queue· PR #23902 landed · regraded p2 → p1 +target:v18· triage seat ·session_01AavokzJ5DndAwitDXvKy4U· 2026-10-16T14:21ZTriage seat (objectstack-wide, seat post #6015). ⛔ Not a claim, ⛔ not a dispatch.
Blocked-by: #23775: [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 closedcompletedat 2026-10-16T13:12Z, when PR feat(metadata-protocol)!: the flow save door refuses a retired spelling on a NEW flow; a stored flow keeps the full chain #23902 merged (cf071687f1). Ruling item 5 (6216220018) asks for one PR after feat(metadata-protocol)!: the flow save door refuses a retired spelling on a NEW flow; a stored flow keeps the full chain #23902 lands, extending itsflowSaveReplaysRetiredpredicate. That PR can start now.target:v18, not p2: the ruling itself names the changeClause-②: yes (narrowing)of the save door's accept set. A narrowing of a/metadoor is the protocol-first class. The maintainer's grading rule makes it p1 +target:v18in every lane (「立卡,规则扩到所有车道,REST 收窄也算」), and this seat's intake6216058760read it the same way.domain:engine) and shape are unchanged. Only the grade moves. If the director meant p2 against that rule, it can be set back here.objectstack-fleet commentedon Oct 16, 2026
Claim: PM loop round 1 · 2026-10-16T14:52Z · on ruling 6216220018 (letter B′ (i) yes · C (ii) no) and triage's unlock 6217547376
Session:
session_01TQLy3Yq6vF4SDpTXdr3UwiAccount:
os-tesla(the seat's linked user asGET /useranswers it; always the card's assignee)Branch:
claude/issue-23903-flow-save-reintroduced-retiredWorktree:
objectstack-issue-23903Domain:
domain:engineSeat:
domain:engine#2(seat post #20966)File surface:
packages/metadata-protocol/src/protocol.ts, onlyflowSaveReplaysRetiredand the flow branch of the save door that calls it (the predicate PR feat(metadata-protocol)!: the flow save door refuses a retired spelling on a NEW flow; a stored flow keeps the full chain #23902 added);packages/metadata-protocol/src/**, plus one REST-door test beside PR feat(metadata-protocol)!: the flow save door refuses a retired spelling on a NEW flow; a stored flow keeps the full chain #23902'smeta-flow-save-retired-spelling.test.ts;docs/adr/0087-metadata-protocol-upgrade-contract.md: one dated note after feat(metadata-protocol)!: the flow save door refuses a retired spelling on a NEW flow; a stored flow keeps the full chain #23902's 2026-10-16 note, which B′ makes incomplete (it says a re-save of a stored row keeps the full chain);.changeset/23903-flow-save-reintroduced-retired.md.⛔ Not
packages/services/service-automation/src/engine.ts: PR #23954 (F5-http of #23543,domain:spec) holds the callout rename andregisterNodeExecutorthere. If the hit comparison cannot be built from whatcanonicalizeStoredFlow/applyConversionsToFlowalready return, stop and report. Stop on any other breach and explain it in the report.Container & model:
M,mode:subagent,model: default(dispatch-gates --tierat6d1660dc3: no path-derived mandate)Clause-②: yes (narrowing)
Responsibility: n/a — a ruling's execution card, not a defect card
Thread-read: 6217547376
Serial constraints cleared: PR #23902 (#23775) is merged (
cf071687f1), which spendsBlocked-by: #23775. PR #23954 (F5-http) touchesservice-automation/src/engine.tsand notprotocol.tsor ADR-0087, and is outside this surface. Its reservation ofhttp_request/http_call/webhookmeans this card's pins use retired spellings that F5-http does not move. No other open PR touchesprotocol.tsor ADR-0087. Read 2026-10-16T14:52Z.Gates this card owes (ruling item 5):
CONTRACT_REVIEW_TIER, for the narrowing of the save door's accept set for existing rows.Generated by Claude Code
wip(metadata-protocol): flow save predicate compares retired hits aga…
wip(rest): REST-door pins for a node added with a retired spelling (#…
docs(adr-0087): dated note for ruling B-prime; changeset for the flow…
fix(metadata-protocol): spell the index site without a bracket-openin…
objectstack-fleet commentedon Oct 16, 2026
os-dev-report
{
"issue": 23903,
"status": "done",
"branch": "claude/issue-23903-flow-save-reintroduced-retired",
"pr": "#23986",
"session": "session_01TQLy3Yq6vF4SDpTXdr3Uwi — the dispatching PM session (this run is its subagent; the harness-stamped Claude-Session line on every commit names it)",
"premise_still_valid": true,
"summary": "Ruling B′ (6216220018 item 2) implemented at head c8e2d67 (base 6d1660d, origin/main 0b69564 merged once, no conflict, no lockfile move). flowSaveReplaysRetired (protocol.ts) now compares conversion hits when a stored row exists: a retired hit in the request that no stored row carries answers false (canonicalizer and the no-engine fallback verdict both run includeRetired:false, so the save meets 422 INVALID_METADATA located with the os validate text, draft and publish alike); a request whose retired hits the stored row all carries keeps the full chain (served legacy body re-saves, healed as before). New flow, server-stated writes, rehydration and the read path unchanged; C not taken. HIT-REPORT READING (assumptions 1-3): (1) both hosts report hits identically — engine canonicalizeStoredFlow(...,{includeRetired:true}).notices and spec applyConversionsToFlow(...,{onNotice}) give the same conversionId/from/path, measured on one body with four retired sites incl. a loop-region node; neither carries a retired flag, ALL_CONVERSIONS[id].retiredFromLoadPath answers it; the predicate scans with the SPEC pass on both hosts (full chain minus FLOW_FALLBACK_VERDICT_NOT_REPLAYED = the engine's CONVERSIONS_NOT_REPLAYED_AT_REHYDRATION), so no host split per request and a canonicalizer throw cannot hide a hit. (2) the stored body is repo.headAt(...).head.body, no new repository call; one added read: the active row for a DRAFT save whose draft row exists but does not carry a request hit (same address #23902 reads for its fallback). (3) notice paths are index-based (flows[0].nodes[2].config.filter); identity = conversionId + from + path with every id-carrying array element resolved to [#id] on the converted body — entry-only fails the add-a-node-beside-the-same-spelling pin (leg 2), raw index fails node-insertion (leg 3); no engine change, no invented location. PIN SPELLINGS (assumption 4): no node TYPE is retired at base — the only type rename, flow-node-http-callout-rename, is live and is the single registry hunk PR #23954 (F5-http) retires; pins use delete_record config.filters (flow-node-crud-filter-alias, names
filter) and wait loose config.duration beside its block (flow-node-wait-event-config-lift, names waitEventConfig.timerDuration; the REST file's existing spelling). After F5-http lands its three names are covered with no change here. OTHER DOORS (assumption 5): holds — server-stated writes short-circuit before any read; the runtime flow-definition door saves authored bodies and meets the predicate; the org-ownership ceremony's conflict re-save sends the body the row was just written with (carried by construction); publishMetaItem is a promotion; no MCP metadata-write tool in this repo. TWO READINGS put to the approval in the PR body and maintainer brief: (a) for a draft save the active row carries hits even when a draft row exists — load-bearing for Studio, whose buffer is not replaced after a draft save (objectui 63d00da DraftVersionGuard), so on an engine host its 2nd autosave of a legacy flow would otherwise be refused; (b) measured divergence: a publish-mode client re-sending a stale legacy body after a heal is refused on an engine host (422 nodes.0.config.filters) and accepted with no automation service (that host stores the raw body), see open_questions[0]. DEVIATIONS: file surface kept (predicate + two comment paragraphs in the calling flow branch) plus one import line (ALL_CONVERSIONS from @objectstack/spec) outside the named symbols; engine.ts untouched. No label writes. Lines: +379/-7 over 5 files — .changeset/23903-flow-save-reintroduced-retired.md +32, docs/adr/0087-metadata-protocol-upgrade-contract.md +2, packages/metadata-protocol/src/protocol.ts +82/-6, packages/metadata-protocol/src/protocol.save-flow-canonicalization.test.ts +187/-1, packages/rest/src/meta-flow-save-retired-spelling.test.ts +76. Governed: check-governed-merges --pr 23986 exit 3, 'GOVERNED ... landing tier: H(人合)', 1 of 5 paths on the register (docs/adr/0087), 386 changed lines under the 3000 threshold. PR is draft, assignee os-tesla, body read back byte-identical (15728 bytes).","tests": "metadata-protocol full suite at c8e2d67 (os-verify-lock, pnpm --filter @objectstack/metadata-protocol exec vitest run --maxWorkers=2): 'Test Files 242 passed | 3 skipped (245) / Tests 20629 passed | 19 skipped (20648)', VERDICT command-exit 0. Earlier full run at 0cef5ed: 1 failed (protocol.bracketed-refusal-opener-absence.test.ts read the new
[${index}]template literal as a refusal tag); f40fd15 respelled it, the two files then 58/58. protocol.save-flow-canonicalization.test.ts 51/51 (34 before + 17 new: 8 pins x 2 hosts + the option pin). rest src/meta-flow-save-retired-spelling.test.ts 10/10 at c8e2d67 with metadata-protocol rebuilt (6 before + 4 new). typecheck: metadata-protocol exit 0 (test file in its tsc program, --listFiles count 1); rest exit 0 incl. check:test-typecheck 'compiles under packages/rest/tsconfig.test.json; 0 error(s)'. eslint --no-inline-config on the 3 touched ts files exit 0. rest full suite / test:repo declared to CI. ABLATION (ablation-replace.mjs wrap mode, restore = blob==HEAD + empty git diff HEAD every leg): leg 1 comparison dropped (request hits forced empty = #23902's predicate): protocol 13 failed/38 passed; REST 3 failed/7 passed (the three refusals) after rebuild, ablation-dist-preflight marker ABLATION_23903_L1 present exit 0. leg 2 location dropped (hit = entry+spelling): protocol 9 failed/42 passed; REST 3 failed/7 passed after rebuild, marker ABLATION_23903_L2 present exit 0 (first REST attempt produced NO reading: mutated dts build failed TS6133 unused siteOf; rerun with a replacement keeping the locals used). leg 3 node-id resolution dropped (raw index): protocol 2 failed/49 passed (node-in-front pin, both hosts); first attempt was a no-op the tool refused (replacement a substring of the anchor, count 1 to 1), rerun non-overlapping; rerun again on the final spelling f40fd15: 2 failed. leg 4 active-row evidence dropped once a draft row exists: protocol 1 failed/50 passed (Studio's 2nd autosave, engine host). Legs 1,2,4 read on protocol.ts blob cd5449d4e; f40fd15 touches none of their anchors. Restore: metadata-protocol rebuilt from HEAD, ablation-dist-preflight --absent exit 0 for both markers ('working tree clean against HEAD'), REST 10/10, protocol 51/51. One-off probe (not committed, file deleted, tree clean): two publish saves of one stale legacy body over a seeded legacy row — engine host first=saved stored filter, second=422 INVALID_METADATA nodes.0.config.filters; no automation service first=saved stored filters, second=saved.","gates": "Local: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack at c8e2d67 (merge base 0b69564, stderr names the repo and commit): 74 commands, identical to the PM's list at 6d1660d (diff exit 0). All 74 run, exit captured before any pipe (output redirected to a log file, then ex=$?): 74 exit 0 — pnpm check:dual-build-cjs-loads first exit 3 'PREREQUISITE NOT MET' (unbuilt workspace), re-run after check:type-check-debt built the closure: exit 0. --ran reconciliation: 'Run reconciliation — 74 derived, 74 run, 0 NOT-MEASURED, 0 UNRUN' ('a DERIVED zero — all 74 recorded an exit code'). Artifact-roster rows in touched directories: node scripts/check-changeset-fixed.mjs exit 0, pnpm check:authz-resolver exit 0, pnpm check:error-code-casing exit 0. Non --commands run read: 51 roster families, 11 declared WIDE-population families, 4 type-check lanes, 5 path-scheduled CI jobs (Test Core, Temporal Conformance, Dogfood Regression, Dogfood Verify CLI, Build Core) — CI's. CI on PR #23986 head c8e2d67, one read at report time: 23 check-runs — 'No other open PR may claim the same issue' success, 'Packed-tarball smoke (opt-in)' skipped, the other 21 in_progress (incl. Check Changeset, Governed Surface Queue Guard, Lint · ESLint, Type Check · workspace, Type Check · source gates); Test Core / Build Core not yet listed. NOT MEASURED: CI families, reason: in_progress at report time (convergence is the PM's).",
"mcp_calls": "0 — no MCP GitHub tool called",
"api_writes": "2 — via the fleet-write relay (each stroke one POST /repos/objectstack-ai/objectstack/dispatches under the session token, executed as objectstack-fleet[bot]): (1) pr_create = POST /repos/objectstack-ai/objectstack/pulls (draft) + POST /repos//issues/23986/assignees [os-tesla], run 38930483181 success, body read back identical; (2) this os-dev-report comment = POST /repos//issues/23903/comments. Not REST: git push (empty-branch probe, 5 commits, 1 merge). Zero label writes.",
"open_questions": [
{
"question": "B′'s letter compares against the stored row, and the two hosts store different rows after a carried re-save (an engine host heals at rest; a host with no automation service stores the raw body, #21982). So a publish-mode client (REST/MCP, not Studio) that re-sends its stale legacy body after a healing save is refused on an engine host (422 at the old node's key, with the prescription) and accepted on a no-engine host. Keep it?",
"options": [
"A keep (as implemented): the ruling's letter; one answer per request and stored row; the client is told the canonical key. Axes: ① follows the ruling and keeps one predicate; ② zero measured pull — Studio publishes by promotion and reloads, the in-repo producers never meet it; ③ loud and taught on engine hosts, the normal deployment; ④ zero code.",
"B also count a site as carried when the stored row already holds that site's canonical result (the request's converted value at the hit's destination equals the stored row's). Axes: ① host-consistent sequences but a second notion of 'carried' to maintain; ② same zero pull; ③ weaker — a stale legacy spelling with an unchanged value passes silently, and removal-type hits compare whole containers (false refusals when a sibling key changes); ④ one more comparison carried forever.",
"C make the no-engine host persist the converted body on a carried re-save so both hosts heal at rest. Axes: ① converges the stored state, the real source of the divergence; ② zero pull; ③ both hosts then refuse the stale re-save alike; ④ changes #21982's deliberate 'stored as written' on control-plane hosts, a separate card and decision."
],
"recommendation": "A, because it is the ruling's letter, the divergence comes from what each host already stores rather than from this predicate, Studio is covered by the draft-row reading, and B would weaken ③ for no measured pull; C is the long-term convergence if a no-engine host ever writes flows in earnest."
},
{
"question": "For a DRAFT save, the predicate also lets the ACTIVE row carry a request's hits when a draft row exists (PR #23902 read it only when none exists). Without it, Studio's second autosave of a legacy flow on an engine host is refused, because the first save healed the draft row and Studio keeps its legacy buffer. Confirm this reading?",
"options": [
"A confirm (as implemented): draft baseline = draft row plus the active row it overlays. Axes: ① one baseline notion for drafts, the one Studio edits against; ② real pull — Studio is the in-repo flow editor and autosaves drafts; ③ an added node is still refused (pinned), only inherited spellings pass; ④ one extra indexed read only when the draft row misses a hit.",
"B draft row only (the strict letter). Axes: ① simpler; ② breaks Studio editing of legacy flows on engine hosts from the 2nd autosave, against ruling 6212082860 item 2; ③ refuses spellings the author never wrote; ④ zero code."
],
"recommendation": "A, because it is what keeps ruling 6212082860's 'a legacy row stays editable in Studio' true under B′, and the add-node refusal still holds on drafts (pins and ablation leg 4)."
}
],
"out_of_scope_findings": [
"carrier: none · noted, not filed — scripts/pm/dispatch-gates.mjs prints 'A card that only NARROWS one is
Clause-②: no' while AGENTS.md's declaration grammar and ruling 6216220018 item 5 sayClause-②: yes (narrowing); this PR follows the ruling as PR #23902 did; read-only prose drift, no public door (dedupe words: clause2 narrowing dispatch-gates prose)","carrier: none · noted, not filed — a node whose id is renamed while keeping a retired spelling reads as a new node and is refused (a consequence of id-keyed sites, in the PR's Acceptance notes)"
]
}
Generated by Claude Code