Skip to content

feat(metadata-protocol)!: a flow re-save that newly introduces a retired spelling is refused like a new flow's - #23986

Merged
os-zhuang merged 5 commits into
mainfrom
claude/issue-23903-flow-save-reintroduced-retired
Oct 17, 2026
Merged

os-zhuang merged 5 commits into
mainfrom
claude/issue-23903-flow-save-reintroduced-retired

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #23903

Clause-②: yes (narrowing)

Ruling-ref: 6216220018 (letter B′ (i) yes · C (ii) no). Claim: 6218148190. Session: session_01TQLy3Yq6vF4SDpTXdr3Uwi. Extends the flowSaveReplaysRetired predicate 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 at CONTRACT_REVIEW_TIER (ruling item 5) belongs to the seat.

What changes

flowSaveReplaysRetired (packages/metadata-protocol/src/protocol.ts), the predicate saveMetaItem's flow branch asks before it canonicalizes, now compares conversion hits when a stored row exists:

  • A retired hit in the request that no stored row carries means authored input. The predicate answers 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 text os validate prints. Draft and publish mode alike.
  • Every retired hit in the request carried by a stored row means 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 has it. The served legacy body re-saves and is healed.
  • Unchanged: a flow with no stored row answers false; the server-stated writes (source set, writeFace: 'package-duplicate' and 'package-unmanaged-import') answer true before any read; the engine's rehydration and the read path are untouched. Ruling item 3 (C) is not taken: convertStoredItem still skips flow.

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's from (the spelling it rewrote), and its site, which is the notice's path with every array element on it that carries an id named [#id] instead of by its index. "Retired" is retiredFromLoadPath on the entry in ALL_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_CONVERSIONS from @objectstack/spec). packages/services/service-automation/src/engine.ts is not touched.

The hit report, measured (the dispatch's assumptions 1 to 5)

Tree: base 6d1660dc3, origin/main 0b6956490 merged in, head c8e2d674a.

  1. Both hosts report hits, identically. The engine's canonicalizeStoredFlow(name, body, { includeRetired: true }) returns notices with keys code, conversionId, surface, toMajor, retiresIn, from, to, path, message; the spec's applyConversionsToFlow(body, { includeRetired: true, onNotice }) emits the same notices. Measured on one body with four retired sites (a wait node's loose config.duration, two config.filters of which one sits inside a loop region, and errorHandling.retryDelayMs): the same conversionId, from and path on both, for example flow-node-crud-filter-alias, filters, flows[0].nodes[3].config.body.nodes[0].config.filter. Neither carries a retired flag; ALL_CONVERSIONS answers it by id. The predicate scans with the spec's pass on BOTH hosts (the full chain minus FLOW_FALLBACK_VERDICT_NOT_REPLAYED, which equals the engine's CONVERSIONS_NOT_REPLAYED_AT_REHYDRATION), so its answer cannot depend on the host (build: a script node's undeclared config key passes objectstack validate, compile and registerFlow, then fails every run — the key half of #21898's class (subflow by reading) #21982), and a canonicalizer throw cannot hide a hit.
  2. The stored row is already read. repo.headAt(...).head.body is 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 address headAt(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.
  3. Location. The notice path is 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, from and path; the predicate resolves each indexed element on the path to its id on the converted body. No engine change, no invented location.
  4. Pin spellings. No node TYPE is retired at this base: the only type rename in the chain, 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: a delete_record node's config.filters (flow-node-crud-filter-alias, refused naming filter) and a wait node's loose config.duration beside its block (flow-node-wait-event-config-lift, refused naming waitEventConfig.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 reads retiredFromLoadPath.
  5. Other doors. The server-stated writes still answer before any read. The runtime flow-definition door (packages/runtime/src/domains/automation.ts) saves an authored body with no source or writeFace, 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. publishMetaItem promotes 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

  • For a draft save, the active row carries hits even when a draft row exists. Studio's flow designer keeps its buffer after a draft save: objectui 63d00da, DraftVersionGuard.save holds 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.
  • A publish-mode client that re-sends a stale legacy body after a heal is refused on an engine host and accepted on a host with no automation service. Measured with a one-off probe (not committed): two publish saves of the same legacy body over a seeded legacy row. Engine host: first saved, row stored as filter, second 422 INVALID_METADATA at nodes.0.config.filters. No automation service: first saved, row stored as filters, second saved. 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)

Pin Protocol door, both hosts (protocol.save-flow-canonicalization.test.ts) REST door (meta-flow-save-retired-spelling.test.ts, real stack, no automation service)
Adding a node with a retired spelling the stored row does not carry: 422 INVALID_METADATA at the new node's key, naming the new key; the stored row untouched publish (nodes.1.config.duration, waitEventConfig.timerDuration)
The stored row carries the SAME spelling on another node; the added node's is still refused (the location pin) publish (nodes.1.config.filters, filter) publish (issues nodes.1.config.duration and nodes.2.config.duration, the new one naming waitEventConfig.timerDuration)
A retired spelling newly written on a node the stored row holds canonical publish (nodes.0.config.filters)
Draft mode with no draft row (the active row is the baseline) refused refused, nothing stored
Draft mode over a stored draft row refused, draft untouched refused, draft untouched
Re-saving the served legacy body unchanged saves; engine host stores it healed, the other stores it as written GET then PUT of the item as served: 200
A canonical node added in front of a carried one saves (the site is the id)
Studio's repeated draft autosaves of a legacy flow after a healing first save; an added node still refused both hosts
The canonicalizer is asked includeRetired: false on an introduced hit, true on a carried one engine stub
A new flow with a retired spelling: 422 (unchanged from B) PR #23902's pins, unchanged, green PR #23902's pins, green

The engine host is the spec's own pass behind the canonicalizer's option, so a carried re-save is stored canonical as AutomationEngine.canonicalizeStoredFlow stores 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 empty git diff HEAD)

Leg Mutation Red Green after restore
1 comparison the request's hits forced empty: any stored row means the full chain (PR #23902's predicate) protocol 13 failed / 38 passed (both hosts' five introduce pins, the Studio pin's third save, the option pin); REST 3 failed / 7 passed (the three refusals); dist rebuilt, ablation-dist-preflight marker present, exit 0 yes
2 location a hit is entry and spelling only, the site dropped protocol 9 failed / 42 passed; REST 3 failed / 7 passed; dist rebuilt, marker present, exit 0 yes
3 node id the raw index path, ids not resolved protocol 2 failed / 49 passed (the node-in-front pin, both hosts) yes
4 draft evidence the active row not read once a draft row exists protocol 1 failed / 50 passed (Studio's second autosave, engine host) yes
  • Leg 3's first attempt was a no-op the tool refused: its replacement was a substring of its anchor, so the replacement count stayed 1 to 1 and nothing ran. It was re-run with a non-overlapping replacement, and run again after f40fd1543 changed the line it mutates: 2 failed both times.
  • REST leg 2's first attempt produced no reading: the mutated build failed its declaration step (TS6133, siteOf unused). It was re-run with a replacement that keeps the locals used.
  • Legs 1, 2 and 4 were read on protocol.ts blob cd5449d4e; f40fd1543 touches none of their anchors.
  • Restore: metadata-protocol rebuilt from HEAD and ablation-dist-preflight --absent exit 0 for both markers (working tree clean); REST 10/10, protocol 51/51.

Tests

  • @objectstack/metadata-protocol, full suite at c8e2d674a: 242 files passed, 3 skipped; 20629 tests passed, 19 skipped (VERDICT command-exit 0). An earlier full run at 0cef5ed02 had one failure: protocol.bracketed-refusal-opener-absence.test.ts read the new line's template literal opening with an interpolated bracket as a refusal tag. f40fd1543 spells that branch with concatenation; the two files then passed 58/58.
  • protocol.save-flow-canonicalization.test.ts: 51/51 (34 before, 17 new).
  • @objectstack/rest src/meta-flow-save-retired-spelling.test.ts at c8e2d674a, metadata-protocol rebuilt: 10/10 (6 before, 4 new).
  • typecheck: metadata-protocol exit 0 (the test file is in its tsc program, --listFiles count 1); rest exit 0, its check:test-typecheck compiling the test layer under tsconfig.test.json.
  • ESLint on the three touched source and test files: exit 0. Repo-wide pnpm lint and test:repo belong to CI.

Gates

  • dispatch-gates.mjs --commands at c8e2d674a (merge base 0b6956490): 74 commands, byte-identical to the PM's list at 6d1660dc3. All 74 ran, every exit captured before any pipe: 74 exit 0. check:dual-build-cjs-loads first exited 3 (PREREQUISITE NOT MET, unbuilt workspace) and was re-run once check:type-check-debt had built the closure: exit 0. --ran reconciliation: 74 derived, 74 run, 0 NOT-MEASURED, 0 UNRUN, a derived zero.
  • The three artifact-roster rows whose roster sits in a directory this diff touches: check-changeset-fixed exit 0, check:authz-resolver exit 0, check:error-code-casing exit 0.
  • The 11 declared wide-population families, the type-check lanes and the five path-scheduled CI jobs are CI's; the PR's check-runs are the reading.

Acceptance notes

维护者速读(草稿)

改了什么
通过元数据接口(Studio 保存、REST /meta、运行时的 flow 接口)再次保存一个已存在的 flow 时,如果请求里出现了库里原行没有的旧拼写,比如往旧 flow 里新加一个节点仍用旧写法,或者在原来已是新写法的节点上改回旧写法,现在会像新建 flow 一样被拒收:返回 422,指出具体位置和应改成的新写法,与 os validate 说的一样。原行本来就带的旧拼写照旧在保存时自愈,原样保存读到的旧 flow 不受影响。

为什么改
执行裁决 6216220018(B′):再存时新引入的旧拼写是作者输入,应与新建 flow 一样拒收。#23902 只拦新建 flow,往旧 flow 里新加的旧写法仍被悄悄改写,写入方(尤其是 AI)学不到正确写法。

风险与代价(含回滚)

  • 拒收时会把原行已有的旧拼写也一并列出(裁决要求整体按作者口径判定);只改新加的那一处就能保存。
  • 本 PR 的一个解读:草稿保存时,草稿下面的已发布行也算"原行"。否则在带自动化服务的主机上,Studio 第一次自动保存把草稿行治愈后,第二次自动保存会被误拒。请审批时确认。
  • 已知差异:API 客户端(不是 Studio)在一次保存治愈原行之后,又用自己手里的旧副本以发布模式再存:带自动化服务的主机拒收(422,附改法),不带自动化服务的主机接受(它存的是原文,原行仍带旧拼写)。Studio 走"草稿保存 + 发布",发布后重新加载,碰不到这种情况。
  • 回滚:还原本 PR 即回到 feat(metadata-protocol)!: the flow save door refuses a retired spelling on a NEW flow; a stored flow keeps the full chain #23902 的行为(有原行就整条转换链自愈),不涉及数据迁移。

席位意见

你要做的
审阅并批准(Tier H:ADR-0087 加了一条日期注记)。请顺带确认上面两条解读:草稿保存把已发布行算作原行;发布模式下旧副本在两类主机上的差异按裁决字面处理。


Generated by Claude Code

@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Oct 16, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/metadata-protocol, touching 3 documentable anchor(s).

5 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/concepts/metadata-lifecycle.mdx (via ObjectStackProtocolImplementation (symbol, a top-level class), saveMetaItem (symbol, a method of class ObjectStackProtocolImplementation))
  • content/docs/deployment/validating-metadata.mdx (via saveMetaItem (symbol, a method of class ObjectStackProtocolImplementation))
  • content/docs/kernel/cluster.mdx (via saveMetaItem (symbol, a method of class ObjectStackProtocolImplementation))
  • content/docs/kernel/services-checklist.mdx (via saveMetaItem (symbol, a method of class ObjectStackProtocolImplementation))
  • content/docs/permissions/authorization.mdx (via saveMetaItem (symbol, a method of class ObjectStackProtocolImplementation))

⛔ 2 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v16.mdx (via ObjectStackProtocolImplementation (symbol, a top-level class))
  • content/docs/releases/v17/17-0.mdx (via ObjectStackProtocolImplementation (symbol, a top-level class))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 11 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 022c6e799e88ae192e124c80e1cc343bda54d7bf → packageMentionDocs.

Which tree this was computed on

This run read content/docs from b4e7f2f556e23ed10da396b69dc72f0afb83fb71 — the merge of head c8e2d674a861f6e74707e5424c2b4524f635d7fd into base 022c6e799e88ae192e124c80e1cc343bda54d7bf, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 022c6e799e88ae192e124c80e1cc343bda54d7bf → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: c8e2d674a861f6e74707e5424c2b4524f635d7fd
Local-runs: none
Scope: docs/adr/0087-metadata-protocol-upgrade-contract.md, packages/metadata-protocol/src/protocol.ts, packages/metadata-protocol/src/protocol.save-flow-canonicalization.test.ts, packages/rest/src/meta-flow-save-retired-spelling.test.ts + packages/metadata-protocol/src/sys-metadata-repository.ts (headAt, rowToItem/storedRowBody), packages/spec/src/conversions/apply.ts (applyConversions, applyConversionsToFlow), packages/spec/src/conversions/walk.ts (mapFlowNodes, mapNodeTree, renameKey), packages/spec/src/conversions/registry.ts (the twelve retired flow entries, flow-node-http-callout-rename, flow-decision-mode-inclusive-explicit), packages/spec/src/conversions/types.ts (ConversionNotice, retiredFromLoadPath), packages/services/service-automation/src/engine.ts (CONVERSIONS_NOT_REPLAYED_AT_REHYDRATION, canonicalizeStoredFlow, canonicalizeFlow), objectui at origin/main a2f38dd, HEAD 63d00da and the pin daeb77fc84: packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx (the flow designer: its load, doSave, autosave, enable switch and package publish), packages/app-shell/src/views/studio-design/useDraftAutoSave.ts, packages/app-shell/src/views/metadata-admin/DraftConflictDialog.tsx (DraftVersionGuard), packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx (the other editor that holds a flow), packages/data-objectstack/src/draft-envelope.ts (extractDraftBody), apps/console/src/components/StudioRoute.tsx and packages/app-shell/src/console/AppContent.tsx (which editor each route mounts); PR #23954's diff to registry.ts, engine.ts and stored.ts. The .changeset file is the prose face, read under ②.

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 main at the head (merge base 0b6956490), the head's check-runs read seven times, open PR #23954.

① Derived judgments

The diff changes one thing a caller can observe: the accept set of the flow save door (saveMetaItem, so PUT /api/v1/meta/flow/:name, the runtime flow-definition door and every protocol caller) for a save over a STORED row. No export, type or schema moves: flowSaveReplaysRetired stays private, the one new import (ALL_CONVERSIONS) is already a public export of @objectstack/spec (packages/spec/src/index.ts:231), packages/rest gains a test only. Each accept-set change, read against ruling 6216220018 items 2 to 5 and 6212082860 where B′ inherits from B:

  1. A publish-mode re-save over a stored row whose body fires a retired entry at a site the stored row does not: refused 422 INVALID_METADATA, located, the os validate text, nothing stored. Right. Item 2's first clause verbatim; item 4's first pin. The mechanism is the letter's: flowSaveReplaysRetired answers false, the engine's canonicalizeStoredFlow is asked { includeRetired: false } (protocol.ts:23120) and the no-engine fallback verdict runs the same option (:23178), so the parse refuses on both hosts (build: a script node's undeclared config key passes objectstack validate, compile and registerFlow, then fails every run — the key half of #21898's class (subflow by reading) #21982 parity kept). Pinned on both hosts in the protocol file and on the real stack at the REST door; the option itself is pinned on the engine stub.

  2. The site identity makes item 4's pin hold when the stored row already carries the SAME spelling on another node. Right, and this is the load-bearing part. A hit is conversionId|from|site, the site being the notice's path with every array element that carries a string id written [#id] (protocol.ts:6654-6681, siteOf and retiredHits). Read in the cited code: every notice the chain can fire carries conversionId, from, path (apply.ts:189-204); applyConversionsToFlow wraps the body as { flows: [flow] } and never throws (apply.ts:244); mapFlowNodes writes node paths flows[i].nodes[j] and region paths ...config.body.nodes[k] / ...config.branches[i].nodes[k] (walk.ts:179, mapNodeTree); the twelve retired flow entries all emit a KEY name as from (filters, config.duration, retryDelayMs, outputSchema, fallbackNodeId, config.actionType, waitEventConfig.timeoutMs, …), so a changed VALUE on a carried key is not a new hit, only a new spelling or a new site is. Ids are resolved on the converted body, which no entry reorders. So a node added with config.filters beside a stored config.filters is [#n2] against a carried [#n1]: a new hit, refused at nodes.1.config.filters naming filter (pinned, both hosts; the REST pin does it with config.duration, issues nodes.1.config.duration and nodes.2.config.duration, the second naming waitEventConfig.timerDuration). A canonical node inserted in front keeps [#n1] carried (pinned). An entry-only or raw-index identity fails exactly these pins (the dev's ablation legs 2 and 3; consistent with the code). Three consequences of an id-keyed site, all inside the letter and all loud: a node whose id is renamed is a new site (the dev's acceptance note); a node moved ACROSS regions (top level into a loop body) is a new site, so the docblock's "moving a node never turns a carried hit into a new one" is true only within one array; a parallel branch carries name, not id (control-flow.zod.ts:157, :245), so a branch inserted ahead of one whose nodes carry a retired spelling shifts their site to [1] and the re-save is refused as new. None breaks a ruling pin; the refusal names the keys; noted for the approval, not a defect.

  3. Both hosts reach one predicate answer. Right. The predicate scans request and stored row with the spec's pass, includeRetired: true minus FLOW_FALLBACK_VERDICT_NOT_REPLAYED (protocol.ts:3278, ['flow-decision-mode-inclusive-explicit']), which equals the engine's CONVERSIONS_NOT_REPLAYED_AT_REHYDRATION (engine.ts:2252); "retired" is retiredFromLoadPath on ALL_CONVERSIONS. The engine is never asked for the comparison, so a canonicalizer throw cannot hide a hit. At this base the only live flow entry is flow-node-http-callout-rename (registry.ts:68, no retiredFromLoadPath), whose conflict guard is the one place the engine pass and the spec pass could differ, and it is outside the retired set, so no host split today. The stored body compared is headAt(...).head.body, which is storedRowBody(row) verbatim (sys-metadata-repository.ts:776, rowToItem), the row the ruling names.

  4. A re-save whose retired hits the stored row all carries keeps the full chain; the served legacy body re-saves and is healed (engine host) or stored as written (no engine). Right. Item 2's second clause, item 4's second pin, 6212082860 item 2. Pinned on both hosts and at the REST door (GET then PUT of the served item: 200).

  5. A new flow with a retired spelling: refused, unchanged from B. Right. Item 4's third pin; feat(metadata-protocol)!: the flow save door refuses a retired spelling on a NEW flow; a stored flow keeps the full chain #23902's pins stay green and are untouched by the diff (the else if (state !== 'draft') return false leg).

  6. Server-stated writes (source set, package-duplicate, package-unmanaged-import) answer before any read and keep the full chain. Right. Item 2 verbatim; the two lines are unchanged.

  7. The read path and the engine's rehydration are untouched; C is not taken. Right. Item 3; convertStoredItem still skips flow (protocol.ts:6464), engine.ts is not in the diff.

  8. The whole body is judged in the authoring posture on an introduced hit, so the 422 also lists the carried legacy sites. Within item 2's letter ("judged with includeRetired: false"). A consequence the changeset states, with the fix (correct the added site; the chain then heals the rest). Not a deviation.

  9. DEVIATION from the one-row letter, escalated: for a DRAFT save with a draft row, the ACTIVE row is read as a second carrier (protocol.ts:6691-6701). The letter names one row: 6212082860 item 1 "the lookup the door already makes for its baseline … draft or publish mode does not change it", feat(metadata-protocol)!: the flow save door refuses a retired spelling on a NEW flow; a stored flow keeps the full chain #23902's ADR note "the row the save overwrites, or for a draft with none yet the active row it overlays", and B′ item 2 "the stored row's body". feat(metadata-protocol)!: the flow save door refuses a retired spelling on a NEW flow; a stored flow keeps the full chain #23902 read the active row only when no draft row exists; this diff reads it also when the draft row carries none of a request's hits. The premise given for it, that Studio's flow designer keeps its buffer after a draft save so on an engine host its second autosave of a legacy flow would be refused, is measured true in the designer: packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx, the Automations pillar of /studio/:packageId/automations (mounted by apps/console/src/components/StudioRoute.tsx:262), identical in shape at objectui origin/main a2f38dd, HEAD 63d00da and the pin daeb77fc84; lines at a2f38dd:

    • the load seeds the buffer with the served draft, or with the published row (client.layered(...).effective) when no draft row exists (L7062-L7073); extractDraftBody strips the read decorations and nothing else (packages/data-objectstack/src/draft-envelope.ts:59), and flows are served verbatim, so a legacy flow's buffer holds the legacy spellings;
    • doSave (L7105-L7123) sends that buffer { mode: 'draft' } through saveFlowDraft, bound at L6836-L6840 from useDraftSaveGuard (DraftVersionGuard, DraftConflictDialog.tsx:208-318, holds a version pin per item and passes the item through); on success it does setHasDraft(true) and setAutoDirty(false) and nothing else: no draft re-read, no setDraft, no reload; only the conflict outcome 'reloaded' replaces the buffer (L7112, reloadFlowDraft L6835). The enable switch saves the same buffer (L7175);
    • useDraftAutoSave (L7133-L7141; useDraftAutoSave.ts:90-164) re-sends the buffer 1.5 s after each later edit.
      So on an engine host the designer's first autosave of a legacy flow has no draft row, the active row carries the spellings, the full chain runs and the draft row is stored healed; the buffer keeps the legacy spellings; the next edit's autosave re-sends them over the healed draft row while the active row still carries them. Judged against the draft row alone, that save is refused with the carried sites listed, and 6212082860 item 2's "a legacy row stays editable in Studio" is false under B′ from the designer's second autosave on. The union reading is the one that keeps it true: the active row the draft overlays is the row the author was served, so a spelling it carries is inherited, not authored, which is the line the ruling draws. It is bounded on the far side by the designer's own publish: doPublish (the package-level publish-drafts, or publishDraft per flow in the package-less scope) bumps publishNonce (L1353, L1435), the flow load re-runs on it (L7094) and re-seeds the buffer from the now-healed published row, so no post-publish autosave carries a legacy spelling. Beyond the designer the reading decides one thing: a non-Studio draft-mode client (REST, or an MCP write tool if one is ever registered) that re-sends a stale legacy body over a healed draft row while the active row still carries the spelling is healed rather than refused; the same client in publish mode is refused (③ flag 1). The other editor that can hold a flow, metadata-admin/ResourceEditPage.tsx (/metadata/flow/:name, AppContent.tsx:1036 and :1134, with FlowPreview as its canvas), re-reads the draft after every save and replaces its buffer (L1686, L1698, L1703, L1706), so it never needs the carrier and is evidence for neither side. The deviation is not the ruling's own premise falsified: its premise, that an uncarried hit is authored input, holds for the node an author adds; what is measured is a gap between B's promise and B′'s one-row letter on the designer's second autosave, which the dev closed by reading the baseline as the pair of rows. That is the maintainer's call, requested in the PR's approval (③ flag 2). Its effect on the accept set is bounded: in that one case it keeps the ruled B's answer (accepted, healed) where the one-row letter refuses; it is never wider than main today; the add-a-node refusal still holds on drafts over a stored draft row (pinned, both hosts and REST). The pin "Studio's draft autosaves of a legacy flow keep saving after the first one healed the draft row … and an added node is still refused" models the designer's measured loop; the ADR note's parenthetical "(for a draft, the active row it overlays as well as its own)" records the reading. Both stand if the maintainer confirms it; both change if the maintainer takes the one-row letter.
  10. After 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) lands, the predicate and the pins stay correct. Read in its diff: flow-node-http-callout-rename gains retiredFromLoadPath: true, retiredAfter: '17.7.0', loses the conflict guard and emits { from: type, to: canonical, path: ${path}.type } unchanged in shape; apply.ts, types.ts, FLOW_FALLBACK_VERDICT_NOT_REPLAYED and CONVERSIONS_NOT_REPLAYED_AT_REHYDRATION are not touched; stored.ts changes a docblock only and protocol.ts still skips flow on read. So the three names enter this predicate's retired set by retiredFromLoadPath with no edit here; an added http_request node is a new site [#id].type, judged includeRetired: false, and meets 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's parse refusal at nodes.N.type naming http; a carried one still renames to http under the full chain. With the guard gone, the engine pass and the spec pass agree on every host for that entry too. The pins use delete_record config.filters and wait loose config.duration, which 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 does not move. 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's own "known gap: the metadata save door" paragraph and its changeset sentence become stale once this lands; that is 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's seat's edit, not this PR's.

② Semver level

.changeset/23903-flow-save-reintroduced-retired.md: '@objectstack/metadata-protocol': minor, title feat(metadata-protocol)!:, Clause-②: yes (narrowing), a BREAKING paragraph whose first sentence names the re-save case (ruling item 5), the FROM → TO migration for every spelling the door now refuses, and exactly one ADR-0087 marker, not-required (already-registered …) naming twelve D2 ids. Matches what the diff publishes: a narrowing of a published door's accept set is BREAKING under AGENTS.md's grammar ("(narrowing) is BREAKING"), yes takes at least minor, and check-changeset-no-major.mjs keeps every breaking change at minor in the launch window, so minor is the repo's breaking level. The twelve ids are exactly the retired flow-surface entries the door can fire (registry.ts surface: 'flow…' rows minus flow-decision-mode-inclusive-explicit, which the door excludes by id, and minus flow-node-http-callout-rename, live at this base; its retirement is #23954's registration). @objectstack/rest publishes nothing from this diff (a test file), so no second changeset is owed. The PR body's Clause-②: yes (narrowing) matches the changeset and ruling item 5; Check Changeset and Governed Surface Queue Guard concluded success on the head. The dev's out-of-scope note that scripts/pm/dispatch-gates.mjs (L11799, L12021) prints "a card that only NARROWS one is Clause-②: no" is prose drift against AGENTS.md's grammar and the ruling; the ruling governs and this PR follows it as #23902 did.

③ Boundary flags

Every dev flag and both open_questions entries:

  1. open_questions[0] — a publish-mode client re-sending its stale legacy body after a healing save is refused on an engine host and accepted on a host with no automation service. Within the ruling's letter; answerable: keep (the dev's A). Item 2 compares the request with "the stored row's body". The two hosts store different rows by two standing records, the 2026-08-02 save-seam addendum ([P2] A Studio edit heals every legacy row except a flow's — saveMetaItem re-persists the pre-protocol dialect an author was served #4542: an engine host persists storable) and build: a script node's undeclared config key passes objectstack validate, compile and registerFlow, then fails every run — the key half of #21898's class (subflow by reading) #21982 (a no-engine host stores the body as written), so one predicate reads two rows and answers twice. The divergence is in what is stored, not in this predicate; B would add a second notion of "carried" the ruling does not name and would let a stale legacy spelling pass silently; C is a change to build: a script node's undeclared config key passes objectstack validate, compile and registerFlow, then fails every run — the key half of #21898's class (subflow by reading) #21982's deliberate design, a separate card. The approval sees it as a consequence of the letter, not a question. Studio does not meet it: the designer never sends a publish-mode save; it promotes drafts by package (StudioDesignSurface.tsx L1308-L1446) and re-seeds its buffer from the published row on publishNonce (L7094).

  2. open_questions[1] — the draft baseline includes the active row when a draft row exists. An interpretation the maintainer must confirm; escalated in the PR's approval, with its premise standing. The letter names one row (① item 9). The premise the PR puts to the maintainer is measured true for Studio's flow designer at objectui a2f38dd, 63d00da and the pin daeb77fc84 (StudioDesignSurface.tsx L7062-L7073, L7105-L7123, L7133-L7141, L7175). The seat tells the maintainer three things in 「席位意见」: (a) what is being confirmed: for a draft save, 「原行」 is the draft row AND the published row it overlays, read as a pair, where the ruling's text names one row; (b) why: without the pair, on a host with an automation service the designer's second autosave of a legacy flow is refused with spellings the author never wrote, because its first autosave healed the draft row and the designer keeps its buffer until a conflict reload or a publish, so B′'s one-row letter breaks 6212082860 item 2 (「a legacy row stays editable in Studio」) from the second autosave on; taking the one-row letter instead would require an objectui change first (the designer re-reading the draft after each save, as ResourceEditPage.tsx L1686-L1706 already does) before this PR could land without that breakage on engine hosts; (c) what the pair decides beyond Studio: a non-Studio draft-mode client re-sending a stale legacy body over a healed draft row, while the published row still carries the spelling, is healed rather than refused, whereas the same client in publish mode is refused (flag 1); an added node is refused in every case. If the maintainer confirms, the ADR note's parenthetical and the pin named after Studio stand as written. If the maintainer takes the one-row letter, the diff changes: the active-row read drops once a draft row exists (protocol.ts:6691-6701, the if (target.head) branch returns false when uncarried remains), the ADR note's parenthetical goes, the Studio pin is inverted or removed, an objectui card for the designer's post-save re-read is filed first, and a new head gets a new record.

  3. Dev deviation — one import line outside the named symbols (ALL_CONVERSIONS from @objectstack/spec). Answered: inside the claimed surface (protocol.ts), a public export, no new package dependency; engine.ts untouched as the claim required.

  4. Dev acceptance note — the refusal lists the carried sites beside the introduced one. Answered: the letter (① item 8); the changeset tells the client the one-site fix.

  5. Dev acceptance note — a node whose id is renamed while keeping a retired spelling is refused as new. Answered: the letter (the site is the id); add to the same family, for the approval's awareness, the cross-region move and the id-less parallel branch (① item 2). Not a ruling question; a follow-up card only if a measured client meets it.

  6. Dev acceptance note — no MCP metadata-write tool in this repository, no MCP pin constructible. Answered: 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 stands; ruling item 4's "the same through the REST door" is pinned on the real stack.

  7. Dev out-of-scope — dispatch-gates.mjs prose says a narrowing-only card is Clause-②: no. Noted under ②; not this PR's.

  8. Check-runs on the head. Read seven times (last at the writing of this record): 32 success, 6 skipped, 0 failed; still in_progress: Test Core (3/6). Test Core (1/6), (2/6), (4/6), (5/6), (6/6), Build Core, every Type Check lane (workspace, source gates, consumer gates, debt ledger), every Lint lane, Check Changeset, Governed Surface Queue Guard, Check PR Size, the claim and single-writer guards have concluded success. The verdict below is on what was judged; the one Test Core shard is named here as not yet concluded, and the PR lands on nothing until it does.

  9. Tier H. The ADR-0087 line is a governed path; the approval is the maintainer's. Item 9 of ① is the one thing that approval decides beyond the ruling's letter.

Implemented-by: claude/issue-23903-flow-save-reintroduced-retired
Reviewed-by: session_01TQLy3Yq6vF4SDpTXdr3Uwi

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

维护者速读(终稿)· PR #23986 · #23903 裁决 B′ · domain:engine seat 2 · 2026-10-16T16:43Z

改了什么:通过元数据接口(Studio、REST /meta、运行时的 flow 接口)再次保存一个已存在的 flow 时,如果请求里出现了库里原行没有的旧写法,现在会像新建 flow 一样被拒收。典型情况是往旧 flow 里新加一个节点仍用旧写法,或在原来已是新写法的节点上改回旧写法。拒收返回 422,指出具体位置和应改成的新写法,措辞与 os validate 一致。原行本来就带的旧写法照旧在保存时自愈,把读到的旧 flow 原样存回不受影响。ADR-0087 在 #23902 的注记后面加了一条日期注记,没有改写原文。

为什么改:执行你 2026-10-16 对 #23903 的裁决 B′(6216220018)。#23902 只拦新建 flow,往旧 flow 里新加的旧写法仍被悄悄改写后存下,写入方(尤其是 AI)学不到正确写法。现在只要是作者新写进来的旧写法,一律得到响亮的拒绝和改法。

风险与代价(含回滚):

席位意见:建议批准。

你要做的:在 PR #23986 上批准(Approve)。批准即确认上面那一处解读:草稿保存时,「原行」= 草稿行 + 它覆盖的已发布行。


席位核对 · ✅ ACCEPT (Tier H, awaiting an authorised approval): PR #23986 at c8e2d674a8 · domain:engine seat 2 · session_01TQLy3Yq6vF4SDpTXdr3Uwi · 2026-10-16T16:43Z

Basis:

  • the ruling 6216220018 (B′ (i) yes, C (ii) no) and the prior ruling 6212082860 (B) it refines;
  • the claim 6218148190 and triage's unlock 6217547376;
  • the dev's report 6219774643;
  • the contract review record 6220234352 (CONTRACT_REVIEW_TIER, PASS on this head). It was adopted after a tool-use audit of the reviewer's transcript: 96 read-only shell calls, 2 writes (both to its scratch record path), no MCP call, no relay tool. Its first rendering was not adopted; see below.
  • the seat's own reading of every non-test hunk, and of Studio's flow designer in objectui.

Checklist

Out of scope, one line each:


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

CI update to the 席位核对 6220257669 · domain:engine seat 2 · session_01TQLy3Yq6vF4SDpTXdr3Uwi · 2026-10-16T16:48Z: ci-failure.mjs --pr 23986 reads GREEN on c8e2d674a8, all 40 check-runs completed, none failed. The head now waits only on an authorised approval.


Generated by Claude Code

This was referenced Oct 16, 2026
@os-zhuang
os-zhuang marked this pull request as ready for review October 17, 2026 00:29
@os-zhuang
os-zhuang added this pull request to the merge queue Oct 17, 2026
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Landing state after the approval · domain:engine seat 2 · session_01TQLy3Yq6vF4SDpTXdr3Uwi · 2026-10-17T00:31Z. An update to the 席位核对 6220257669.

  • Approval: review 5555068170 by os-zhuang, APPROVED on the current head c8e2d674a8. os-zhuang is in GOVERNED_APPROVERS (scripts/pm/check-governed-queue-guard.mjs), so Tier H (docs/adr/0087-metadata-protocol-upgrade-contract.md) is satisfied for this head. The approval confirms the reading the brief named: a draft save's baseline is the draft row plus the active row it overlays. os-zhuang also marked the PR ready.
  • Gate on the head: ci-failure.mjs --pr 23986: GREEN, 40 of 40. Governed Surface Queue Guard re-ran after the approval and passed. git merge-tree against current origin/main (8a83cebc15) is clean.
  • needs-user-decision is removed in this act: the decision is answered.
  • Not done by the seat: auto-merge is not armed. This session's permission check refused that action. Landing waits on the maintainer: a merge by hand, or arming auto-merge.
  • After landing: a pointer goes to 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's seat. Its "known gap: the metadata save door" paragraph and its changeset sentence become stale once this lands (contract record 6220234352, item 10).

Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Correction to 6227374639 · domain:engine seat 2 · session_01TQLy3Yq6vF4SDpTXdr3Uwi · 2026-10-17T00:32Z. os-zhuang added this PR to the merge queue at 00:29:04Z, a minute after approving it, and a gh-readonly-queue/main/*pr-23986* head exists. So landing does not wait on the maintainer: it is in the queue. The seat posts the landing record, and the pointer to #23954's seat, when it merges.


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

3 participants