Skip to content

Commit 0712470

Browse files
fix(metadata-protocol): a flow read's _diagnostics and the restore doors judge a stored flow in its D2-converted spelling (#29753)
<!-- claude-projects-attribution: {"github_login":"os-warren"} --> _Requested by **Warren** · [project thread](https://claude.ai/code/project/chan_01U5RdnAAmkSQmZbqh6FtqZ8?thread=cmsg_01U5RdnAAmkSQmZbqh6FtqZ85ZKppiwDrpyUp1d2RvAzUX)_ Fixes #29696 Clause-②: yes (widening) Metadata-protocol: none ## 维护者速读 - 改了什么:流程的读取 `_diagnostics`(单项、列表、`?layers=true`、草稿读取、诊断汇总)的 schema 判定,以及两道恢复门(`rollbackMetaItem`、`revertCommit`)的运行时规则,改为按 D2 转换后的拼写判定流程,与保存门、两道发布门同一条缝 `storedFlowVerdictBody`;返回与恢复的仍是存储原字节。 - 为什么改:旧拼写的流程行(如 get_record 的 `filters`)保存、发布、引擎注册都能过,读取却标 `valid: false`,恢复历史版本则被 422 拒掉,同一行几处答案不一。 - 风险与回滚:只放宽(转换后仍不合格的行照旧 `valid: false`、恢复照旧被拒,错误与新拼写一致);回滚即 revert 本 PR。 - 先看:`metadata-diagnostics.ts` 的 `computeMetadataDiagnostics`(读取判定入参)、`protocol-authoring-gate.ts` 的 `refuseRestoredBodyTheSaveDoorRefuses`、`protocol-flow-save-replay.ts` 的 `storedFlowVerdictBody` 注释(转换链与引擎一致的实测说明)。 - 两分钟验证:`pnpm --filter @objectstack/metadata-protocol exec vitest run --maxWorkers=2 src/protocol.draft-publish-verdict.test.ts src/protocol.restore-doors-save-refusal-family.test.ts -t 29696`,预期 9 条全绿。 - 需要你做的:无,席位落地。 ## What changes - **The read's schema half.** `computeMetadataDiagnostics` judges a `flow` through `storedFlowVerdictBody` (the seam the save door's fallback verdict and both publish doors judge through, PR #29694). Every read exit that decorates reaches it: `getMetaItem` (active and draft), `getMetaItems`, `getMetaItemLayered` and the `getMetaDiagnostics` sweep. The read serves the body it was handed; the converted body is the verdict's only. Not a redaction, so the #8154 order in `decorateMetadataItem` is untouched (the body judged is still the unredacted one; no flow redactor or cleartext-credential refusal exists in the flow schema). - **The restore doors.** `refuseRestoredBodyTheSaveDoorRefuses` (both `rollbackMetaItem` and `revertCommit`'s restore limb) hands the runtime authoring rules `storedFlowVerdictBody(type, body)`, as `promoteDraftForPublish` does for a flow draft. The version is restored as stored; the credential read and every other refusal in the family read the stored version. The restore doors still do not ask the schema gate (its docblock's stored-shape reason stands). - **The excluded conversion, settled in the docblock.** Measured: the engine's rehydration (`canonicalizeFlow` in `service-automation/src/flow-definitions.ts`) replays the full chain with retired entries and excludes `CONVERSIONS_NOT_REPLAYED_AT_REHYDRATION`, which names the same one id as `FLOW_FALLBACK_VERDICT_NOT_REPLAYED` (`flow-decision-mode-inclusive-explicit`). So `storedFlowVerdictBody` already replays exactly the engine's chain. `storedFlowVerdictBody`'s docblock now says so, says why the excluded entry never decides acceptance (it is a default flip: an omitted decision `mode` parses and means exclusive), cites the maintainer ruling (letter C on #15429) that it is not replayed over stored flows, and drops the old claim that `applyConversionsToStoredItem` replays this chain on read (no read converts a flow: `convertStoredItem` skips `flow`). No converter added; no conversion list changed. - No `packages/spec` change. ## Why A flow row at rest in a legacy ADR-0087 D2 spelling (`get_record` `config.filters`) is accepted by the save door, both publish doors and the engine, but its reads answered `valid: false` and both restore doors refused a history version in that spelling. Measured red on `origin/main` `0eb97fa4`, the pins committed first (`e905c02c`, fix not yet applied): <details><summary>Red, before the fix</summary> ```text × the active item read reads valid: true and serves the stored bytes × the list read reads valid: true and serves the stored bytes × the layered read reads valid: true (first run: the double lacked registry.getItem; fixed in 3bf9be7, red for the right reason in the ablation below) × the draft read of a re-save of it reads valid: true × a row still refused once converted reads valid: false, with the errors its canonical spelling reads × rollbackMetaItem: answers INVALID_METADATA / 422; nothing is stored × rollbackMetaItem CONTROL: an acceptable version of the same item is restored × revertCommit CONTROL: the restore limb restores an acceptable version of the same item Tests 8 failed | 1 passed | 84 skipped (93) read: { valid: false, errors: [ { path: 'nodes.1.config.filters', code: 'custom', ... get_record contract: Unrecognized key(s) ... `filters` } ] } restore: Error: flow/acme_fetch failed author-time validation: 2 issues — flows[0].nodes[1].config.filters [expression-invalid]; flows[0] [flow-region-shape-invalid] ``` </details> <details><summary>The excluded conversion, measured against the built spec (scratch probe, not committed)</summary> ```text start → decision (two conditioned out-edges, no mode) → two ends constructed raw valid: true constructed stored-verdict chain valid: true mode: (omitted) constructed full chain valid: true mode: inclusive ``` The flip changes meaning, never acceptance: no body is refused for want of it. </details> ## Tests - New top-level `describe('[#29696] …')` in `packages/metadata-protocol/src/protocol.draft-publish-verdict.test.ts` (the #29557 harness; its legacy-flow fixtures are hoisted to module scope so both blocks share them). A legacy row seeded at rest: the active item read, the list read, the layered read and the draft read of a re-save each answer `_diagnostics: { valid: true }` and serve `config.filters` as stored. A legacy row that also carries an undeclared config key reads `valid: false` with exactly the `_diagnostics` its `filter` twin reads. - New `pinBothDoors` case in `packages/metadata-protocol/src/protocol.restore-doors-save-refusal-family.test.ts`: a legacy history version restores at both doors (the active row equals the history bytes, `filters` included); a legacy version that also carries an edge whose CEL does not parse is refused 422 `INVALID_METADATA` at both doors on `expression-invalid`, with nothing reported at `filters`, and stores nothing. - The #29557 block's parenthetical saying the read's schema half still judged the stored body is replaced with a pointer to the new block. <details><summary>Ablation (fix committed first at d2c880d, mutated through scripts/ablation-replace.mjs, restored to the HEAD blob)</summary> ```text leg 1: metadata-diagnostics.ts withoutResolvedPicklistOptions(storedFlowVerdictBody(singular, stripReadDecorations(item))) -> withoutResolvedPicklistOptions(stripReadDecorations(item)) × the active item read / the list read / the layered read / the draft read / a row still refused once converted Tests 5 failed | 4 passed | 84 skipped (93) ok restored: blob == HEAD (2125c9e) and git diff HEAD is empty leg 2: protocol-authoring-gate.ts body: storedFlowVerdictBody(type, body), -> body, × rollbackMetaItem refused / rollbackMetaItem CONTROL / revertCommit CONTROL Tests 3 failed | 6 passed | 84 skipped (93) ok restored: blob == HEAD (6fb69ae) and git diff HEAD is empty ``` </details> ## Acceptance notes - The boot census in `protocol.ts` (`[metadata_spec_invalid] … fails the current spec schema even after conversion`) reads `computeMetadataDiagnostics` too, so a legacy D2 flow row no longer draws that warning: for a flow the sentence was false before (no read converts a flow), and the engine registers the row. Not separately pinned. - A still-refused flow's `_diagnostics` error paths are located on the converted body, as the publish doors' issues are (#29557); for a key rename such as `filters` → `filter` the path of an unrelated finding is unchanged. - `runAuthoringGate` (plugin-registered gates) on the restore path still receives the stored version. No in-tree plugin registers a `flow` gate (only `object`), and the publish promotion does not call it at all; left as is. - `FLOW_FALLBACK_VERDICT_NOT_REPLAYED` and `CONVERSIONS_NOT_REPLAYED_AT_REHYDRATION` stay tied by prose only: neither is on its package's published surface, and metadata-protocol cannot import service-automation (the dependency runs the other way). Noted, not filed. - PR template headings are folded into the os-dev section order. ## Verification All readings on head `760e096f` unless named (the fix `d2c880d5` plus a test-only fixture-typing commit). `origin/main` moved 10 commits since the branch point `0eb97fa4`; none touches `packages/metadata-protocol` or the spec's conversion or automation sources, so no merge was taken. - `pnpm --filter @objectstack/metadata-protocol test`: Test Files 308 passed, 4 skipped; Tests 22277 passed, 26 skipped (lock VERDICT command-exit 0; run on `d2c880d5`). On `760e096f` the two touched test files: 2 files, 93 tests passed. - `pnpm --filter @objectstack/metadata-protocol typecheck`: exit 0 on `760e096f` (exit 2 on `d2c880d5`: a TS2352 cast in the new fixture, fixed in `760e096f`). - `dispatch-gates --commands` on `760e096f`: 61 derived; `--ran` reconciliation: 60 run, 1 NOT MEASURED (`check:dual-build-cjs-loads`, exit 3 PREREQUISITE NOT MET: it reads every package's dist and this worktree built only the touched closure; CI runs it), 0 unrun. `check-plugin-teardown-shape --self-test` first answered exit 3 (shallow clone lacked its pinned fixture commit) and exit 0 after `git fetch origin 621a487`. `pnpm lint` exit 0. <details><summary>Gate record (command :: exit code)</summary> ```text node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0 node scripts/check-adr-0087-registration.mjs --self-test :: exit 0 node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0 node scripts/check-closing-keyword-parity.mjs :: exit 0 node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0 node scripts/check-comment-mask-adoption.mjs :: exit 0 node scripts/check-comment-mask-adoption.mjs --self-test :: exit 0 node scripts/check-comment-mask-corpus.mjs :: exit 0 node scripts/check-dts-emitted.mjs --self-test :: exit 0 node scripts/check-empty-changeset.mjs --base origin/main :: exit 0 node scripts/check-empty-changeset.mjs --self-test :: exit 0 node scripts/check-issue-citations.mjs :: exit 0 node scripts/check-plugin-teardown-shape.mjs :: exit 0 node scripts/check-plugin-teardown-shape.mjs --self-test :: exit 0 node scripts/ci/assert-turbo-remote-cache.mjs --self-test :: exit 0 node scripts/ci/plan-test-core-shards.mjs --self-test :: exit 0 node scripts/ci/prune-turbo-cache.mjs --self-test :: exit 0 node scripts/docs-audit/check-affected-docs.mjs :: exit 0 node scripts/docs-audit/check-drift-comment.mjs :: exit 0 node scripts/pm/release-rehearsal-clone.mjs --self-test :: exit 0 node scripts/release-pending-publish.mjs --self-test :: exit 0 pnpm --filter @objectstack/spec run check:duration-unit-keys :: exit 0 pnpm check:changeset-gate-self-tests :: exit 0 pnpm check:ci-filter-parity :: exit 0 pnpm check:closed-ceremony-text :: exit 0 pnpm check:cross-package-test-inputs :: exit 0 pnpm check:dispatcher-error-vocabulary :: exit 0 pnpm check:doc-authoring :: exit 0 pnpm check:driver-memory-census :: exit 0 pnpm check:dts-closure :: exit 0 pnpm check:dual-build-cjs-loads :: exit 3 pnpm check:durability-log-level :: exit 0 pnpm check:engine-double-contract :: exit 0 pnpm check:error-code-casing :: exit 0 pnpm check:error-status-conformance :: exit 0 pnpm check:filter-alias-parity :: exit 0 pnpm check:gitlink-declared :: exit 0 pnpm check:issue-citations :: exit 0 pnpm check:keyed-text-bounds :: exit 0 pnpm check:lean-entry-closure :: exit 0 pnpm check:logger-receiver-detach :: exit 0 pnpm check:nul-bytes :: exit 0 pnpm check:objectql-double-limit :: exit 0 pnpm check:org-identifier :: exit 0 pnpm check:page-declaration-shape :: exit 0 pnpm check:platform-object-tenancy-census :: exit 0 pnpm check:published-files :: exit 0 pnpm check:query-options-erasure :: exit 0 pnpm check:refd-timer-probe :: exit 0 pnpm check:registry-log-declared :: exit 0 pnpm check:rest-log-spy-declared :: exit 0 pnpm check:slot-lookup :: exit 0 pnpm check:sourcemap-no-sources-content :: exit 0 pnpm check:system-context-census :: exit 0 pnpm check:test-source-alias :: exit 0 pnpm check:tier-file-adoption :: exit 0 pnpm check:type-check-coverage :: exit 0 pnpm check:type-check-debt :: exit 0 pnpm check:undeclared-dep-imports :: exit 0 pnpm check:watch-hint-literal :: exit 0 pnpm check:where-matcher :: exit 0 pnpm lint :: exit 0 ``` </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01JQwxbaKY2r8oRQXNGhYHHg)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent d0f308c commit 0712470

7 files changed

Lines changed: 245 additions & 51 deletions
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@objectstack/metadata-protocol': patch
3+
---
4+
5+
fix(metadata-protocol): a flow read's `_diagnostics` and the restore doors judge a stored flow in its D2-converted spelling, as the save and publish doors do
6+
7+
Clause-②: yes (widening)
8+
Metadata-protocol: none
9+
10+
A flow stored in a legacy ADR-0087 D2 spelling that the automation engine registers (a `get_record` node's `config.filters`, for one) is accepted by the save door and both publish doors, but every read of it (`GET /meta/flow/:name`, the list read, the `?layers=true` read, the draft read and the diagnostics sweep) answered `_diagnostics: { valid: false }` naming the legacy key, and both restore doors (`rollbackMetaItem`, `revertCommit`) refused a history version in that spelling with `422 INVALID_METADATA`. The read's schema verdict and the restore doors' runtime rules now judge the row through the doors' conversion seam, with the chain the engine replays when it registers a stored row, and serve or restore the row as stored. A flow the rules still refuse once converted still reads `valid: false`, with the errors its current spelling gets, and a version refused once converted is still refused at restore.

‎packages/metadata-protocol/src/metadata-diagnostics.ts‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import { zodIssuesToMetadataIssues } from './protocol.js';
3838
// landed by #8300). Composed into `decorateMetadataItem` below — see the
3939
// ordering note there for why it is not applied per read exit.
4040
import { redactMetadataItem } from './metadata-redaction.js';
41+
import { storedFlowVerdictBody } from './protocol-flow-save-replay.js';
4142
// [#26707] The snapshot key a write type's findings are spelled under — the
4243
// runtime gate's own answer, so the publish verdict below is re-spelled
4344
// against the same prefix the gate wrote it with.
@@ -276,7 +277,18 @@ export function computeMetadataDiagnostics(
276277
// [#26886] …and judge each picklist-bound field as its author wrote it:
277278
// the `options` the runtime resolved onto it are not part of that shape
278279
// ({@link withoutResolvedPicklistOptions}).
279-
const candidate = withoutResolvedPicklistOptions(stripReadDecorations(item));
280+
//
281+
// [#29696] …and judge a FLOW in the spelling every door judges it in: its
282+
// ADR-0087 D2-converted body ({@link storedFlowVerdictBody}, the seam the
283+
// save door's fallback verdict, both publish doors and the restore doors
284+
// judge through). A served flow is a row at rest that no read converts
285+
// (`convertStoredItem` skips `flow`; the engine converts it when it
286+
// registers it), so judged as stored a legacy D2 spelling (`get_record`
287+
// `config.filters`) read `valid: false` while every door and the engine
288+
// accepted it. For the VERDICT only: the read serves the body it was
289+
// handed. Not a redaction, so the #8154 order below is untouched: the
290+
// body judged is still the unredacted one.
291+
const candidate = withoutResolvedPicklistOptions(storedFlowVerdictBody(singular, stripReadDecorations(item)));
280292

281293
// [#25783] A document parsed before against this same schema is answered
282294
// from the memo — see "The verdict memo" above.

‎packages/metadata-protocol/src/protocol-authoring-gate.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,11 @@ export async function refuseRestoredBodyTheSaveDoorRefuses(
160160
type,
161161
name,
162162
state: 'active',
163-
body,
163+
// [#29696] A flow version is judged in its D2-converted spelling, as
164+
// both publish doors judge a stored flow draft (#29557): a history
165+
// body is a row at rest. Restored as stored; the credential read below
166+
// reads the stored version.
167+
body: storedFlowVerdictBody(type, body),
164168
source,
165169
packageId: binding,
166170
...(channel

‎packages/metadata-protocol/src/protocol-flow-save-replay.ts‎

Lines changed: 30 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,12 @@
3939
* publish verdict (`draftPublishVerdict`) judge. One judge, one spelling: a
4040
* legacy D2 spelling the save door accepted and stored raw is accepted at
4141
* publish too, and what goes live is still the stored bytes.
42+
*
43+
* [#29696] The two other judges of a flow at rest judge through it too: the
44+
* read's `_diagnostics` schema half (`computeMetadataDiagnostics`, every read
45+
* exit that decorates) and the restore doors' runtime rules
46+
* (`refuseRestoredBodyTheSaveDoorRefuses`, for `rollbackMetaItem` and
47+
* `revertCommit`). Each serves or restores the body as stored.
4248
*/
4349

4450
import { ALL_CONVERSIONS, applyConversionsToFlow, type ConversionNotice } from '@objectstack/spec';
@@ -141,19 +147,32 @@ export function flowVerdictBody(body: unknown, options: { includeRetired: boolea
141147
}
142148

143149
/**
144-
* [#29557] The body a publish judges a STORED draft in: for a `flow`, the
145-
* {@link flowVerdictBody} of the draft row with the retired entries included;
146-
* every other type, the draft as stored. The two publish doors and the draft
147-
* read's publish verdict judge through here and promote, store and serve the
148-
* draft as stored.
150+
* [#29557] The body a STORED flow is judged in: for a `flow`, the
151+
* {@link flowVerdictBody} of the row with the retired entries included; every
152+
* other type, the row as stored. The two publish doors and the draft read's
153+
* publish verdict judge a stored draft through here, [#29696] the read's
154+
* `_diagnostics` schema half judges a served row and the restore doors' runtime
155+
* rules a history version; each promotes, serves or restores the row as stored.
156+
*
157+
* A stored row is data at rest, so the chain is the one the automation engine
158+
* replays when it registers the row: its stored-flow canonicalizer
159+
* (`canonicalizeFlow` in `service-automation/src/flow-definitions.ts`), which
160+
* every caller but the save door calls with no options, so retired entries
161+
* included, and which excludes `CONVERSIONS_NOT_REPLAYED_AT_REHYDRATION`. That
162+
* list and {@link FLOW_FALLBACK_VERDICT_NOT_REPLAYED} name the same one entry,
163+
* `flow-decision-mode-inclusive-explicit`, so this verdict replays exactly the
164+
* engine's chain. The entry is a DEFAULT FLIP, not a rescue: it writes
165+
* `mode: 'inclusive'` where a decision omits `mode`, and an omitted `mode`
166+
* parses (it means exclusive), so leaving it out never turns an accepted body
167+
* into a refused one; it only keeps the verdict from judging a meaning the row
168+
* does not have. By maintainer ruling (letter C on #15429) it is not replayed
169+
* over stored flows at all. No read path converts a flow either:
170+
* `convertStoredItem` skips `flow`, and `applyConversionsToStoredItem` is not
171+
* applied to it.
149172
*
150-
* A stored draft is a row at rest, so the chain is the full one: the one the
151-
* automation engine replays when it registers the published row (its stored-
152-
* flow canonicalizer, which every caller but the save door calls with no
153-
* options) and the one `applyConversionsToStoredItem` replays on read. The
154-
* retired-entry predicate of the save door ({@link flowSaveReplaysRetired},
173+
* The retired-entry predicate of the save door ({@link flowSaveReplaysRetired},
155174
* #23903 ruling B') is not asked again: it judges authored input, and the
156-
* draft's own save asked it, in draft mode as in publish mode. `type` is the
175+
* row's own save asked it, in draft mode as in publish mode. `type` is the
157176
* singular type name.
158177
*/
159178
export function storedFlowVerdictBody(type: string, body: unknown): unknown {

‎packages/metadata-protocol/src/protocol.draft-publish-verdict.test.ts‎

Lines changed: 131 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -975,6 +975,44 @@ describe('[#29007] a stored object carrying the retired titleFormat', () => {
975975
});
976976
});
977977

978+
// [#29557, #29696] The legacy-D2 flow fixtures the publish doors (#29557) and the
979+
// reads (#29696) are both pinned on.
980+
981+
/** start → get_record → end, the match map spelled under `filterKey`. */
982+
const fetchFlow = (name: string, filterKey: 'filters' | 'filter') => ({
983+
name,
984+
label: name,
985+
type: 'autolaunched',
986+
nodes: [
987+
{ id: 'start', type: 'start', label: 'Start' },
988+
{ id: 'fetch', type: 'get_record', label: 'Fetch', config: { objectName: 'deal', [filterKey]: { name: 'Acme' }, outputVariable: 'deal' } },
989+
{ id: 'end', type: 'end', label: 'End' },
990+
],
991+
edges: [
992+
{ id: 'e1', source: 'start', target: 'fetch' },
993+
{ id: 'e2', source: 'fetch', target: 'end' },
994+
],
995+
});
996+
997+
/** The stored active row a legacy draft is a re-save of. */
998+
async function seedActive(engine: any, body: { name: string }) {
999+
await engine.insert('sys_metadata', {
1000+
type: 'flow',
1001+
name: body.name,
1002+
organization_id: null,
1003+
package_id: PKG,
1004+
state: 'active',
1005+
metadata: JSON.stringify(body),
1006+
checksum: hashSpec(body, 'flow'),
1007+
version: 1,
1008+
});
1009+
}
1010+
1011+
const storedConfig = (rows: Map<string, Row>, name: string, state: 'active' | 'draft') => {
1012+
const row = [...rows.values()].find((r) => r.type === 'flow' && r.name === name && r.state === state);
1013+
return row ? JSON.parse(row.metadata).nodes[1].config : undefined;
1014+
};
1015+
9781016
/**
9791017
* [#29557] A flow draft stored in a legacy ADR-0087 D2 spelling the engine
9801018
* registers (`get_record` `config.filters`, `flow-node-crud-filter-alias`). The
@@ -998,41 +1036,6 @@ describe('[#29557] a stored flow draft in a legacy D2 spelling the engine regist
9981036
warn.mockRestore();
9991037
});
10001038

1001-
/** start → get_record → end, the match map spelled under `filterKey`. */
1002-
const fetchFlow = (name: string, filterKey: 'filters' | 'filter') => ({
1003-
name,
1004-
label: name,
1005-
type: 'autolaunched',
1006-
nodes: [
1007-
{ id: 'start', type: 'start', label: 'Start' },
1008-
{ id: 'fetch', type: 'get_record', label: 'Fetch', config: { objectName: 'deal', [filterKey]: { name: 'Acme' }, outputVariable: 'deal' } },
1009-
{ id: 'end', type: 'end', label: 'End' },
1010-
],
1011-
edges: [
1012-
{ id: 'e1', source: 'start', target: 'fetch' },
1013-
{ id: 'e2', source: 'fetch', target: 'end' },
1014-
],
1015-
});
1016-
1017-
/** The stored active row a legacy draft is a re-save of. */
1018-
async function seedActive(engine: any, body: { name: string }) {
1019-
await engine.insert('sys_metadata', {
1020-
type: 'flow',
1021-
name: body.name,
1022-
organization_id: null,
1023-
package_id: PKG,
1024-
state: 'active',
1025-
metadata: JSON.stringify(body),
1026-
checksum: hashSpec(body, 'flow'),
1027-
version: 1,
1028-
});
1029-
}
1030-
1031-
const storedConfig = (rows: Map<string, Row>, name: string, state: 'active' | 'draft') => {
1032-
const row = [...rows.values()].find((r) => r.type === 'flow' && r.name === name && r.state === state);
1033-
return row ? JSON.parse(row.metadata).nodes[1].config : undefined;
1034-
};
1035-
10361039
async function stageLegacy(name: string, body = fetchFlow(name, 'filters')) {
10371040
const { engine, rows } = makeStubEngine();
10381041
const protocol = new ObjectStackProtocolImplementation(engine);
@@ -1071,8 +1074,7 @@ describe('[#29557] a stored flow draft in a legacy D2 spelling the engine regist
10711074
expect(await (protocol as any).draftPublishVerdict({ type: 'flow', name: 'legacy_fetch', body: stored, packageId: PKG }))
10721075
.toEqual([]);
10731076
// Served, it carries none of the rules the publish refused it on before.
1074-
// (The read's own schema half still judges the served body as stored;
1075-
// that seam is the read's, not the publish verdict's.)
1077+
// ([#29696] The read's own schema half is pinned in its own block below.)
10761078
const codes = ((await draftDiagnostics(protocol, 'flow', 'legacy_fetch')).errors ?? []).map((e) => e.code);
10771079
expect(codes).not.toContain('expression-invalid');
10781080
expect(codes).not.toContain('flow-region-shape-invalid');
@@ -1116,3 +1118,95 @@ describe('[#29557] a stored flow draft in a legacy D2 spelling the engine regist
11161118
expect(issueTriples(batch.failed[0].issues)).toEqual(issueTriples(canonical.err.issues));
11171119
});
11181120
});
1121+
1122+
/**
1123+
* [#29696] A flow READ judges a stored legacy D2 row in the spelling the doors
1124+
* judge it in. A row at rest in a legacy ADR-0087 D2 spelling (`get_record`
1125+
* `config.filters`) is accepted by the save door, both publish doors (#29557)
1126+
* and the engine's canonicalizer; the read's `_diagnostics` schema half judges
1127+
* it through the same seam (`storedFlowVerdictBody`), and serves it as stored.
1128+
* Measured on `origin/main` `0eb97fa4` before the fix: every read below
1129+
* answered `valid: false` with `unrecognized_keys` on `config.filters`.
1130+
*/
1131+
describe('[#29696] a flow read judges a stored legacy D2 row in its D2-converted spelling', () => {
1132+
let warn: ReturnType<typeof vi.spyOn>;
1133+
beforeEach(() => {
1134+
warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
1135+
delete process.env.OS_ALLOW_UNLINTED_METADATA_WRITES;
1136+
});
1137+
afterEach(() => {
1138+
warn.mockRestore();
1139+
});
1140+
1141+
/** The legacy fixture with a config key no conversion rewrites and the parse refuses. */
1142+
const withUndeclaredKey = (name: string, filterKey: 'filters' | 'filter') => {
1143+
const flow = fetchFlow(name, filterKey);
1144+
return {
1145+
...flow,
1146+
nodes: flow.nodes.map((n) => (n.id === 'fetch' ? { ...n, config: { ...n.config, not_a_config_key: true } } : n)),
1147+
};
1148+
};
1149+
1150+
async function seeded(body: { name: string }) {
1151+
const { engine, rows } = makeStubEngine();
1152+
const protocol = new ObjectStackProtocolImplementation(engine);
1153+
await seedActive(engine, body);
1154+
return { engine, rows, protocol };
1155+
}
1156+
1157+
/** The active item read's `_diagnostics` and the config it serves. */
1158+
async function activeRead(protocol: ObjectStackProtocolImplementation, name: string) {
1159+
const read: any = await protocol.getMetaItem({ type: 'flow', name, packageId: PKG } as any);
1160+
return { diagnostics: read.item._diagnostics, config: read.item.nodes[1].config };
1161+
}
1162+
1163+
it('the active item read reads valid: true and serves the stored bytes', async () => {
1164+
const { protocol } = await seeded(fetchFlow('legacy_fetch', 'filters'));
1165+
1166+
const { diagnostics, config } = await activeRead(protocol, 'legacy_fetch');
1167+
1168+
expect(diagnostics).toEqual({ valid: true });
1169+
expect(config).toEqual({ objectName: 'deal', filters: { name: 'Acme' }, outputVariable: 'deal' });
1170+
});
1171+
1172+
it('the list read reads valid: true and serves the stored bytes', async () => {
1173+
const { protocol } = await seeded(fetchFlow('legacy_fetch', 'filters'));
1174+
1175+
const list: any = await protocol.getMetaItems({ type: 'flow', packageId: PKG });
1176+
const item = (list.items as any[]).find((i) => i.name === 'legacy_fetch');
1177+
1178+
expect(item?._diagnostics).toEqual({ valid: true });
1179+
expect(item.nodes[1].config).toEqual({ objectName: 'deal', filters: { name: 'Acme' }, outputVariable: 'deal' });
1180+
});
1181+
1182+
it('the layered read reads valid: true', async () => {
1183+
const { engine, protocol } = await seeded(fetchFlow('legacy_fetch', 'filters'));
1184+
// The layered read asks the registry for the shipped layer; this
1185+
// double ships none.
1186+
engine.registry.getItem = () => undefined;
1187+
1188+
const layered: any = await protocol.getMetaItemLayered({ type: 'flow', name: 'legacy_fetch', packageId: PKG } as any);
1189+
1190+
expect(layered._diagnostics).toEqual({ valid: true });
1191+
});
1192+
1193+
it('the draft read of a re-save of it reads valid: true', async () => {
1194+
const body = fetchFlow('legacy_fetch', 'filters');
1195+
const { rows, protocol } = await seeded(body);
1196+
expect(await stageDraft(protocol, 'flow', body)).toMatchObject({ success: true, state: 'draft' });
1197+
expect(storedConfig(rows, 'legacy_fetch', 'draft')).toEqual({ objectName: 'deal', filters: { name: 'Acme' }, outputVariable: 'deal' });
1198+
1199+
expect(await draftDiagnostics(protocol, 'flow', 'legacy_fetch')).toEqual({ valid: true });
1200+
});
1201+
1202+
it('a row still refused once converted reads valid: false, with the errors its canonical spelling reads', async () => {
1203+
const canonical = await activeRead((await seeded(withUndeclaredKey('refused_fetch', 'filter'))).protocol, 'refused_fetch');
1204+
const legacy = await activeRead((await seeded(withUndeclaredKey('refused_fetch', 'filters'))).protocol, 'refused_fetch');
1205+
1206+
expect(canonical.diagnostics.valid).toBe(false);
1207+
expect(JSON.stringify(canonical.diagnostics.errors)).toContain('not_a_config_key');
1208+
expect(legacy.diagnostics).toEqual(canonical.diagnostics);
1209+
// Served as stored: the legacy spelling is what the read hands back.
1210+
expect(legacy.config.filters).toEqual({ name: 'Acme' });
1211+
});
1212+
});

‎packages/metadata-protocol/src/protocol.restore-doors-save-refusal-family.test.ts‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -591,3 +591,56 @@ pinBothDoors({
591591
},
592592
expectDetail: (error) => expect(error.message).toBe('refused in state active'),
593593
});
594+
595+
// ─── [#29696] A flow version stored in a legacy ADR-0087 D2 spelling ─────────
596+
//
597+
// The restore doors judge a flow version the way both publish doors judge a
598+
// stored flow draft (#29557): the runtime authoring rules read it in its
599+
// D2-converted spelling (`storedFlowVerdictBody`), and the version is restored
600+
// as stored. Measured on `origin/main` `0eb97fa4` before the fix: a version in
601+
// the legacy `get_record` `config.filters` spelling, which the save door, the
602+
// publish doors and the engine all accept, was refused at both restore doors
603+
// (422 `INVALID_METADATA`, `expression-invalid` at `config.filters`).
604+
605+
const LEGACY_FLOW = 'acme_fetch';
606+
/** start → get_record → end, the match map spelled under `filterKey`; `brokenEdge` adds a conditional edge whose CEL does not parse. */
607+
const fetchFlow = (label: string, filterKey: 'filters' | 'filter', brokenEdge = false) => ({
608+
name: LEGACY_FLOW,
609+
label,
610+
type: 'autolaunched',
611+
nodes: [
612+
{ id: 'start', type: 'start', label: 'Start' },
613+
{ id: 'fetch', type: 'get_record', label: 'Fetch', config: { objectName: 'acme_deal', [filterKey]: { name: 'Acme' }, outputVariable: 'deal' } },
614+
{ id: 'end', type: 'end', label: 'End' },
615+
],
616+
edges: [
617+
{ id: 'e1', source: 'start', target: 'fetch' },
618+
brokenEdge
619+
? { id: 'e2', source: 'fetch', target: 'end', type: 'conditional', condition: 'record.name ==' }
620+
: { id: 'e2', source: 'fetch', target: 'end' },
621+
],
622+
});
623+
624+
pinBothDoors({
625+
title: '[#29696] a flow version in a legacy D2 spelling is judged converted: restored as stored, and refused only where its converted spelling is',
626+
type: 'flow',
627+
name: LEGACY_FLOW,
628+
code: 'INVALID_METADATA',
629+
status: 422,
630+
async stage(h) {
631+
await h.protocol.saveMetaItem({ type: 'flow', name: LEGACY_FLOW, item: fetchFlow('v1', 'filter') });
632+
await h.protocol.saveMetaItem({ type: 'flow', name: LEGACY_FLOW, item: fetchFlow('v2', 'filter') });
633+
await h.protocol.saveMetaItem({ type: 'flow', name: LEGACY_FLOW, item: fetchFlow('v3', 'filter') });
634+
// Both versions as rows at rest in the legacy spelling; v2 also
635+
// carries an edge the rules refuse in any spelling.
636+
rewriteHistory(h, 'flow', LEGACY_FLOW, 1, () => fetchFlow('v1', 'filters'));
637+
rewriteHistory(h, 'flow', LEGACY_FLOW, 2, () => fetchFlow('v2', 'filters', true));
638+
return { refused: 2, accepted: 1 };
639+
},
640+
// Refused on the edge alone: nothing is reported at the legacy spelling.
641+
expectDetail: (error) => {
642+
const rules = (error.issues ?? []).map((i: { rule?: string }) => i.rule);
643+
expect(rules).toContain('expression-invalid');
644+
expect(JSON.stringify(error.issues)).not.toContain('filters');
645+
},
646+
});

‎packages/metadata-protocol/src/protocol.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1560,7 +1560,9 @@ export class ObjectStackProtocolImplementation implements
15601560
* ({@link assertRuntimeAuthoringRules}), judged as an `active` write
15611561
* bound to the row's own package, and told which credentials the
15621562
* write-only channel holds — the restore keeps them (R2) — as the draft
1563-
* promotion tells it;
1563+
* promotion tells it; [#29696] a flow version judged in its
1564+
* D2-converted spelling (`storedFlowVerdictBody`), as the promotion
1565+
* judges a flow draft, and restored as stored;
15641566
* - the authoring gate a domain plugin registers
15651567
* ({@link runAuthoringGate}), on the channel the save door asks it on.
15661568
*

0 commit comments

Comments
 (0)