Repository navigation
Commit 6e18016
feat(metadata-protocol)!: a flow re-save that newly introduces a retired spelling is refused like a new flow's (#23986)
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 #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 (#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
#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 #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 #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 #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
- The refusal lists the retired sites the stored row carries beside the
introduced one: the ruling judges the whole body with `includeRetired:
false`. The changeset says that correcting the added site alone is
enough.
- A node whose `id` is renamed while it keeps a retired spelling reads
as a new node and is refused.
- `dispatch-gates.mjs` prints that a card which only narrows is
`Clause-②: no`; AGENTS.md's grammar and the ruling say `yes
(narrowing)`. This PR follows the ruling, as PR #23902 did. Noted, not
filed.
- MCP door: no pin constructible in this repository (PR #23902's reading
stands).
## 维护者速读(草稿)
**改了什么**
通过元数据接口(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 即回到 #23902 的行为(有原行就整条转换链自愈),不涉及数据迁移。
**席位意见**
**你要做的**
审阅并批准(Tier H:ADR-0087
加了一条日期注记)。请顺带确认上面两条解读:草稿保存把已发布行算作原行;发布模式下旧副本在两类主机上的差异按裁决字面处理。
---
_Generated by [Claude
Code](https://claude.ai/code/session_01TQLy3Yq6vF4SDpTXdr3Uwi)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent be076da commit 6e18016
5 files changed
Lines changed: 379 additions & 7 deletions
File tree
- .changeset
- docs/adr
- packages
- metadata-protocol/src
- rest/src
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
573 | 573 | | |
574 | 574 | | |
575 | 575 | | |
| 576 | + | |
| 577 | + | |
576 | 578 | | |
577 | 579 | | |
578 | 580 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
37 | 37 | | |
38 | 38 | | |
39 | 39 | | |
40 | | - | |
| 40 | + | |
41 | 41 | | |
42 | 42 | | |
43 | 43 | | |
| |||
618 | 618 | | |
619 | 619 | | |
620 | 620 | | |
| 621 | + | |
| 622 | + | |
| 623 | + | |
| 624 | + | |
| 625 | + | |
| 626 | + | |
| 627 | + | |
| 628 | + | |
| 629 | + | |
| 630 | + | |
| 631 | + | |
| 632 | + | |
| 633 | + | |
| 634 | + | |
| 635 | + | |
| 636 | + | |
| 637 | + | |
| 638 | + | |
| 639 | + | |
| 640 | + | |
| 641 | + | |
| 642 | + | |
| 643 | + | |
| 644 | + | |
| 645 | + | |
| 646 | + | |
| 647 | + | |
| 648 | + | |
| 649 | + | |
| 650 | + | |
| 651 | + | |
| 652 | + | |
| 653 | + | |
| 654 | + | |
| 655 | + | |
| 656 | + | |
| 657 | + | |
| 658 | + | |
| 659 | + | |
| 660 | + | |
| 661 | + | |
| 662 | + | |
| 663 | + | |
| 664 | + | |
| 665 | + | |
| 666 | + | |
| 667 | + | |
| 668 | + | |
| 669 | + | |
| 670 | + | |
| 671 | + | |
| 672 | + | |
| 673 | + | |
| 674 | + | |
| 675 | + | |
| 676 | + | |
| 677 | + | |
| 678 | + | |
| 679 | + | |
| 680 | + | |
| 681 | + | |
| 682 | + | |
| 683 | + | |
| 684 | + | |
| 685 | + | |
| 686 | + | |
| 687 | + | |
| 688 | + | |
| 689 | + | |
| 690 | + | |
| 691 | + | |
| 692 | + | |
| 693 | + | |
| 694 | + | |
| 695 | + | |
| 696 | + | |
| 697 | + | |
| 698 | + | |
| 699 | + | |
| 700 | + | |
| 701 | + | |
| 702 | + | |
| 703 | + | |
| 704 | + | |
| 705 | + | |
| 706 | + | |
| 707 | + | |
| 708 | + | |
| 709 | + | |
| 710 | + | |
| 711 | + | |
| 712 | + | |
| 713 | + | |
| 714 | + | |
| 715 | + | |
| 716 | + | |
| 717 | + | |
| 718 | + | |
| 719 | + | |
| 720 | + | |
| 721 | + | |
| 722 | + | |
| 723 | + | |
| 724 | + | |
| 725 | + | |
| 726 | + | |
| 727 | + | |
| 728 | + | |
| 729 | + | |
| 730 | + | |
| 731 | + | |
| 732 | + | |
| 733 | + | |
| 734 | + | |
| 735 | + | |
| 736 | + | |
| 737 | + | |
| 738 | + | |
| 739 | + | |
| 740 | + | |
| 741 | + | |
| 742 | + | |
| 743 | + | |
| 744 | + | |
| 745 | + | |
| 746 | + | |
| 747 | + | |
| 748 | + | |
| 749 | + | |
| 750 | + | |
| 751 | + | |
| 752 | + | |
| 753 | + | |
| 754 | + | |
| 755 | + | |
| 756 | + | |
| 757 | + | |
| 758 | + | |
| 759 | + | |
| 760 | + | |
| 761 | + | |
| 762 | + | |
| 763 | + | |
| 764 | + | |
| 765 | + | |
| 766 | + | |
| 767 | + | |
| 768 | + | |
| 769 | + | |
| 770 | + | |
| 771 | + | |
| 772 | + | |
| 773 | + | |
| 774 | + | |
| 775 | + | |
| 776 | + | |
| 777 | + | |
| 778 | + | |
| 779 | + | |
| 780 | + | |
| 781 | + | |
| 782 | + | |
| 783 | + | |
| 784 | + | |
| 785 | + | |
| 786 | + | |
| 787 | + | |
| 788 | + | |
| 789 | + | |
| 790 | + | |
| 791 | + | |
| 792 | + | |
| 793 | + | |
| 794 | + | |
| 795 | + | |
| 796 | + | |
| 797 | + | |
| 798 | + | |
| 799 | + | |
| 800 | + | |
| 801 | + | |
| 802 | + | |
| 803 | + | |
| 804 | + | |
| 805 | + | |
| 806 | + | |
0 commit comments