Skip to content

Commit 0eb97fa

Browse files
fix(metadata-protocol): both publish doors judge a stored flow draft in its D2-converted spelling (#29694)
<!-- claude-projects-attribution: {"github_login":"os-warren"} --> _Requested by **Warren** · [project thread](https://claude.ai/code/project/chan_01U5RdnAAmkSQmZbqh6FtqZ8?thread=cmsg_01U5RdnAAmkSQmZbqh6FtqZ8KoK2L1RPn4y1SCPs9jqdu9)_ Fixes #29557 Clause-②: yes (widening) Metadata-protocol: none ## 维护者速读 - 改了什么:流程草稿在两道发布门(单项发布、包发布)和草稿读取的发布预判里,改为按 D2 转换后的拼写判定,与保存门一致;落库与上线的仍是草稿原始字节。 - 为什么改:早先存下的旧拼写草稿(如 get_record 的 `filters`)保存能过、引擎注册也能过,却在发布时被 422 拒掉,同一流程两道门答案不一。 - 风险与回滚:只放宽(转换后仍不合格的草稿照旧被拒,问题列表与新拼写一致);回滚即 revert 本 PR。 - 先看:`protocol-flow-save-replay.ts`(新共用缝 `storedFlowVerdictBody`)、`protocol.ts` 的 `promoteDraftForPublish` 判定入参、`protocol-authoring-gate.ts` 的 `draftPublishVerdict`。 - 两分钟验证:`pnpm --filter @objectstack/metadata-protocol exec vitest run --maxWorkers=2 src/protocol.draft-publish-verdict.test.ts -t 29557`,预期 4 条全绿。 - 需要你做的:无,席位落地。 ## What changes - **One seam.** `protocol-flow-save-replay.ts` gains `flowVerdictBody(body, { includeRetired })`, the conversion the save door's fallback verdict applies (`applyConversionsToFlow`, the full chain minus `FLOW_FALLBACK_VERDICT_NOT_REPLAYED`), and `storedFlowVerdictBody(type, body)`, that conversion asked of a stored flow draft. The save door's fallback now calls `flowVerdictBody` instead of its inline call; no second converter exists. - **Both publish doors.** `promoteDraftForPublish`, which the per-item publish (`publishMetaItem`) and the package publish (`publishPackageDrafts`) both promote through, hands the runtime authoring gate `storedFlowVerdictBody(singularType, draftForGate.body)`. `repo.promoteDraft` still promotes the stored draft, so the active row is the draft's bytes. The credential read and every other promotion refusal still read the stored draft. - **The draft read's publish verdict** (`draftPublishVerdict`, the `_diagnostics` fold of #26707) judges the same converted body, so a draft read does not report a refusal its publish no longer gives. - **Which chain.** A stored draft is a row at rest, so the publish replays the full chain, retired entries included: the chain the automation engine replays when it registers the published row (`canonicalizeFlow` defaults `includeRetired` to true for every caller but the save door) and the one `applyConversionsToStoredItem` replays on read. The save door's retired-entry predicate (`flowSaveReplaysRetired`, ruling B') is not asked again at publish: it judges authored input, and the draft's own save asked it. - **Lint prose (comment 6587200138).** One sentence in the `validate-flow-region-shape.ts` docblock and one in its `rule-explanations.ts` entry name the runtime gate as a reader of the rule and the body it judges. - No `packages/spec` change, no rule skipped, no carve-out in `packages/lint`. ## Why The engine registers a stored flow in a legacy ADR-0087 D2 spelling (its canonicalizer replays the conversion), and the save door judges the converted body and stores the raw one (#21982). The two publish doors judged the raw stored draft, so such a draft saved and then failed at publish. Measured red on `origin/main` 744211f before the fix, with the card's issues, at both doors: <details><summary>Red, before the fix</summary> ```text × publishes through the per-item door, and the active row is the stored bytes × publishes through the package door, and the active row is the stored bytes × its draft read says it publishes Error: flow/legacy_fetch failed author-time validation: 2 issues — flows[0].nodes[1].config.filters [expression-invalid]; flows[0] [flow-region-shape-invalid] Tests 3 failed | 40 skipped (43) ``` </details> ## Tests New `describe('[#29557] …')` in `packages/metadata-protocol/src/protocol.draft-publish-verdict.test.ts` (the established harness for these doors; no automation service reachable, so every save takes the fallback). The draft is the realistic one: a stored active row carries `get_record` `config.filters`, and the author's draft re-save carries the same hit, so the save replays the full chain and stores it raw. - The per-item door publishes it; the active row carries `filters` as stored; the draft is gone. - The package door publishes it (`publishedCount: 1`); the active row carries `filters` as stored. - `draftPublishVerdict` answers `[]` for the stored draft, and the served `_diagnostics` carry neither `expression-invalid` nor `flow-region-shape-invalid`. - A legacy draft that also carries a conditional edge whose CEL does not parse is refused 422 `INVALID_METADATA` at the per-item door with exactly the (rule, path, message) triples its `filter` twin gets, and the package door refuses it with the same triples and publishes nothing. <details><summary>Ablation (fix committed first, mutated through scripts/ablation-replace.mjs, restored to HEAD blob)</summary> ```text leg 1: protocol.ts body: storedFlowVerdictBody(singularType, draftForGate.body) -> body: draftForGate.body × publishes through the per-item door, and the active row is the stored bytes × publishes through the package door, and the active row is the stored bytes × a draft still refused once converted is refused at both doors with the issues its canonical spelling gets Tests 3 failed | 1 passed | 40 skipped (44) ok restored: blob == HEAD (6b19396) and git diff HEAD is empty leg 2: protocol-authoring-gate.ts body: storedFlowVerdictBody(singular, draft.body) -> body: draft.body × its draft read reports no publish refusal for it Tests 1 failed | 3 passed | 40 skipped (44) ok restored: blob == HEAD (fc9aabb) and git diff HEAD is empty ``` </details> ## Acceptance notes - The served `_diagnostics` of a flow read (draft and active alike) still carry the read's own schema half, which parses the served body as stored: on this harness both the draft read and, after the publish, the active read answer `valid: false` with the `get_record` contract's `filters` refusal. That seam is the read's diagnostics, not the publish verdict, and it predates this change for any legacy active row; reported to the seat, not touched here. - The restore path (`refuseRestoredBodyTheSaveDoorRefuses`) still hands the runtime gate the history body as stored. Not measured; reported to the seat. - `protocol.ts` no longer imports `applyConversionsToFlow` or `FLOW_FALLBACK_VERDICT_NOT_REPLAYED` (both now read in the seam module; the latter is still re-exported). - PR template headings are folded into the os-dev section order. ## Verification All readings on head `3b5f63de7` (the fix `8d76dec4` plus a merge of `origin/main` 0688156; no file overlap). - `pnpm --filter @objectstack/metadata-protocol test`: Test Files 308 passed, 4 skipped; Tests 22266 passed, 26 skipped (lock VERDICT command-exit 0; run on `8d76dec4`, before the merge). - `pnpm --filter @objectstack/lint test`: Test Files 192 passed; Tests 7892 passed (VERDICT command-exit 0; on `8d76dec4`). - `pnpm --filter @objectstack/metadata-protocol typecheck` and `pnpm --filter @objectstack/lint typecheck`: exit 0. - After the merge, rebuilt, on `3b5f63de7`: `vitest run src/protocol.draft-publish-verdict.test.ts src/protocol.save-flow-canonicalization.test.ts src/runtime-authoring-gate.flow-region.test.ts`: 3 files, 102 tests passed. - `dispatch-gates --commands` on `3b5f63de7`: 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` and `check:lean-entry-closure` first answered exit 3 (shallow fixture commit, unbuilt objectql) and exit 0 after `git fetch origin 621a487` and an objectql build. `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/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:docs-transcript-drift :: 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-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: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 node scripts/check-plugin-teardown-shape.mjs --self-test :: exit 0 pnpm check:lean-entry-closure :: exit 0 ``` </details> ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: its generated artifacts are regenerated and committed. (Not touched.) - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. --- _Generated by [Claude Code](https://claude.ai/code/session_01JQwxbYUL4P4uPHYTjwZQz2)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent c9636f1 commit 0eb97fa

8 files changed

Lines changed: 230 additions & 14 deletions
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/metadata-protocol': patch
3+
'@objectstack/lint': patch
4+
---
5+
6+
fix(metadata-protocol): both publish doors judge a stored flow draft in its D2-converted spelling, as the save door does
7+
8+
Clause-②: yes (widening)
9+
Metadata-protocol: none
10+
11+
A flow draft stored in a legacy ADR-0087 D2 spelling that the automation engine registers (a `get_record` node's `config.filters`, for one) saved as a draft, because the save door judges a flow in its converted spelling and stores the body as written, but the per-item publish (`POST /meta/flow/:name/publish`) and the package publish (`publish-drafts`) then refused it with `422 INVALID_METADATA` (`expression-invalid`, `flow-region-shape-invalid`), because both judged the raw stored body. Both doors, and the draft read's publish verdict, now judge the stored draft through the save door's conversion seam, with the full conversion chain the engine replays when it registers a stored row, and promote it as stored: the active row is the draft's bytes. A draft the flow rules still refuse once converted is refused with the issues its current spelling gets.
12+
13+
The `flow-region-shape-invalid` explanation (`os explain`) now also names the server as a reader of the rule and the body it judges.

‎packages/lint/src/rule-explanations.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5515,7 +5515,9 @@ const FLOW_REGION_SHAPE_INVALID_EXPLANATION: RuleExplanation = {
55155515
'same region check the engine runs when it registers the flow, and the message is the engine\'s ' +
55165516
'refusal behind the flow\'s name. Like the engine it reports a flow\'s first malformed region, so a ' +
55175517
'flow with two may need two fixes. A flow the flow schema refuses cannot be judged and is reported ' +
5518-
'as such, with the schema\'s issues.',
5518+
'as such, with the schema\'s issues. The server asks it too, when a flow is saved active or a draft ' +
5519+
'is published, of the flow with its legacy spellings converted to the current ones: the body the ' +
5520+
'engine registers.',
55195521
'Not judged: a cycle in the flow\'s top-level graph, outside any region. The engine refuses that too, ' +
55205522
'with a check this rule cannot reach.',
55215523
'To fix a multi-exit region, add one node every exit of the region leads to; to fix a cycle, remove ' +

‎packages/lint/src/validate-flow-region-shape.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@
33
/**
44
* `flow-region-shape-invalid` — a flow the automation engine refuses to
55
* register for its ADR-0031 region shape, refused at every authoring command.
6+
* The runtime authoring gate reads it too, at the save of an active flow and
7+
* at both publish doors (the per-item publish and the package publish), and
8+
* judges the body the engine registers: the flow in its ADR-0087 D2-converted
9+
* spelling (#21982, #29557).
610
*
711
* ## What it reports
812
*

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ import type { ObjectStackProtocolImplementation } from './protocol.js';
5050
import { permissionSetOwnerBindingRefusal, runtimeHookWithoutBodyRefusal } from './protocol.js';
5151
import { objectBodyExtensionFieldSuffixRefusal } from './protocol-extension-field-suffix.js';
5252
import { datasourceCredentialsRefSaveRefusal } from './protocol-datasource-credentials-ref.js';
53+
import { storedFlowVerdictBody } from './protocol-flow-save-replay.js';
5354
import { evaluateRuntimeAuthoringGate } from './runtime-authoring-gate.js';
5455
import type { PackagedRuleCopyRefusal, RuntimeAuthoringIssue, RuntimeAuthoringVerdict, RuntimePendingDeclarations } from './runtime-authoring-gate.js';
5556
import { savedItemNameRefusal } from '@objectstack/metadata/view-container-name';
@@ -544,7 +545,9 @@ export async function draftPublishVerdict(
544545
type: singular,
545546
name: draft.name,
546547
state: 'active',
547-
body: draft.body,
548+
// [#29557] The body the promotion judges: a flow draft in its
549+
// D2-converted spelling, every other draft as stored.
550+
body: storedFlowVerdictBody(singular, draft.body),
548551
packageId: draft.packageId,
549552
...(pending !== undefined ? { pending } : {}),
550553
...(channel

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

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,16 @@
2929
* imports from it; what it imports from elsewhere, it imports from where
3030
* `protocol.ts` imported it. Nothing re-exports this module, so the published
3131
* surface does not move.
32+
*
33+
* ## [#29557] The flow verdict body, one seam for every door that judges a flow
34+
*
35+
* {@link flowVerdictBody} below is the conversion the save door's fallback
36+
* verdict (#21982) applies, and {@link storedFlowVerdictBody} is that same
37+
* conversion asked of a STORED flow draft: the body the per-item publish, the
38+
* package publish (both through `promoteDraftForPublish`) and the draft read's
39+
* publish verdict (`draftPublishVerdict`) judge. One judge, one spelling: a
40+
* legacy D2 spelling the save door accepted and stored raw is accepted at
41+
* publish too, and what goes live is still the stored bytes.
3242
*/
3343

3444
import { ALL_CONVERSIONS, applyConversionsToFlow, type ConversionNotice } from '@objectstack/spec';
@@ -113,3 +123,39 @@ export async function flowSaveReplaysRetired(
113123
strikeCarried(active.body);
114124
return uncarried.size === 0;
115125
}
126+
127+
/**
128+
* [#21982] The body a flow is JUDGED in when the save door's canonicalizer did
129+
* not resolve or threw: the request body with the spec's ADR-0087 D2
130+
* conversions applied (`applyConversionsToFlow`), the full chain minus
131+
* {@link FLOW_FALLBACK_VERDICT_NOT_REPLAYED}, with the retired entries when
132+
* `includeRetired` says so. For the VERDICT only: the caller stores the body
133+
* it was handed, never this one. A key no conversion rewrites is judged
134+
* exactly as written.
135+
*/
136+
export function flowVerdictBody(body: unknown, options: { includeRetired: boolean }): unknown {
137+
return applyConversionsToFlow(body, {
138+
includeRetired: options.includeRetired,
139+
excludeConversionIds: FLOW_FALLBACK_VERDICT_NOT_REPLAYED,
140+
});
141+
}
142+
143+
/**
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.
149+
*
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},
155+
* #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
157+
* singular type name.
158+
*/
159+
export function storedFlowVerdictBody(type: string, body: unknown): unknown {
160+
return type === 'flow' ? flowVerdictBody(body, { includeRetired: true }) : body;
161+
}

‎packages/metadata-protocol/src/protocol-meta-item-reads.ts‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -117,9 +117,10 @@ export async function readMetaItem(host: Pick<ProtocolHost, 'artifactLockLayerAt
117117
// [#26707] A served DRAFT is decorated as above and then told what its
118118
// publish would refuse: the runtime gate's verdict on the stored draft
119119
// body ({@link draftPublishVerdict}), folded into `_diagnostics`
120-
// ({@link withPublishVerdict}). Judged on the row as stored — the body
121-
// the promotion judges — never on the governed, converted or redacted
122-
// copy this read serves. The execution face serves no `_diagnostics`.
120+
// ({@link withPublishVerdict}). Handed the row as stored — the body the
121+
// promotion reads, and [#29557] converts for its verdict exactly as the
122+
// promotion does — never the governed, converted or redacted copy this
123+
// read serves. The execution face serves no `_diagnostics`.
123124
const serveDraft = async <T>(resolved: T, row: { metadata?: unknown; package_id?: string | null }): Promise<T> => {
124125
const served = await serveFormLayout(serveItem(resolved));
125126
if (execution || !served || typeof served !== 'object') return served;

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

Lines changed: 143 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
*/
3030

3131
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
32-
import { assertEngineDeleteDispatch, assertEngineUpdateDispatch, assertEngineFindOnePredicate } from '@objectstack/metadata-core';
32+
import { assertEngineDeleteDispatch, assertEngineUpdateDispatch, assertEngineFindOnePredicate, hashSpec } from '@objectstack/metadata-core';
3333
import { ObjectStackProtocolImplementation } from './protocol.js';
3434

3535
/** The LIVE object universe: a deal with a formula field, and its account. */
@@ -974,3 +974,145 @@ describe('[#29007] a stored object carrying the retired titleFormat', () => {
974974
expect(saved.success).toBe(true);
975975
});
976976
});
977+
978+
/**
979+
* [#29557] A flow draft stored in a legacy ADR-0087 D2 spelling the engine
980+
* registers (`get_record` `config.filters`, `flow-node-crud-filter-alias`). The
981+
* save door judges a flow on the canonicalizer's fallback in its D2-converted
982+
* spelling and stores the raw body (#21982), so the draft saves; both publish
983+
* doors and the draft read judge the stored draft through the same seam, so it
984+
* publishes, and what goes live is the stored bytes.
985+
*
986+
* The draft is the realistic one: a stored active row carries the spelling (a
987+
* row at rest), and the author's draft re-save of it carries the same hit, so
988+
* the save replays the full chain (#23903 ruling B'). This harness reaches no
989+
* automation service, so every save takes the fallback.
990+
*/
991+
describe('[#29557] a stored flow draft in a legacy D2 spelling the engine registers', () => {
992+
let warn: ReturnType<typeof vi.spyOn>;
993+
beforeEach(() => {
994+
warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
995+
delete process.env.OS_ALLOW_UNLINTED_METADATA_WRITES;
996+
});
997+
afterEach(() => {
998+
warn.mockRestore();
999+
});
1000+
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+
1036+
async function stageLegacy(name: string, body = fetchFlow(name, 'filters')) {
1037+
const { engine, rows } = makeStubEngine();
1038+
const protocol = new ObjectStackProtocolImplementation(engine);
1039+
await seedActive(engine, body);
1040+
const saved = await stageDraft(protocol, 'flow', body);
1041+
expect(saved).toMatchObject({ success: true, state: 'draft' });
1042+
// Stored as written: the conversion is the verdict's, never the row's.
1043+
expect(storedConfig(rows, name, 'draft')).toEqual({ objectName: 'deal', filters: { name: 'Acme' }, outputVariable: 'deal' });
1044+
return { engine, rows, protocol };
1045+
}
1046+
1047+
it('publishes through the per-item door, and the active row is the stored bytes', async () => {
1048+
const { rows, protocol } = await stageLegacy('legacy_fetch');
1049+
1050+
const published: any = await protocol.publishMetaItem({ type: 'flow', name: 'legacy_fetch', packageId: PKG } as any);
1051+
1052+
expect(published.success).toBe(true);
1053+
expect(storedConfig(rows, 'legacy_fetch', 'active')).toEqual({ objectName: 'deal', filters: { name: 'Acme' }, outputVariable: 'deal' });
1054+
expect(storedConfig(rows, 'legacy_fetch', 'draft')).toBeUndefined();
1055+
});
1056+
1057+
it('publishes through the package door, and the active row is the stored bytes', async () => {
1058+
const { rows, protocol } = await stageLegacy('legacy_fetch');
1059+
1060+
const published: any = await protocol.publishPackageDrafts({ packageId: PKG });
1061+
1062+
expect(published).toMatchObject({ success: true, publishedCount: 1, failedCount: 0 });
1063+
expect(storedConfig(rows, 'legacy_fetch', 'active')).toEqual({ objectName: 'deal', filters: { name: 'Acme' }, outputVariable: 'deal' });
1064+
});
1065+
1066+
it('its draft read reports no publish refusal for it', async () => {
1067+
const { rows, protocol } = await stageLegacy('legacy_fetch');
1068+
const stored = JSON.parse([...rows.values()].find((r) => r.name === 'legacy_fetch' && r.state === 'draft')!.metadata);
1069+
1070+
// The draft read's publish verdict, on the stored draft: nothing.
1071+
expect(await (protocol as any).draftPublishVerdict({ type: 'flow', name: 'legacy_fetch', body: stored, packageId: PKG }))
1072+
.toEqual([]);
1073+
// 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.)
1076+
const codes = ((await draftDiagnostics(protocol, 'flow', 'legacy_fetch')).errors ?? []).map((e) => e.code);
1077+
expect(codes).not.toContain('expression-invalid');
1078+
expect(codes).not.toContain('flow-region-shape-invalid');
1079+
});
1080+
1081+
/** The same flow with a conditional edge whose CEL does not parse. */
1082+
const brokenEdge = (name: string, filterKey: 'filters' | 'filter') => {
1083+
const flow = fetchFlow(name, filterKey);
1084+
flow.edges[1] = { ...flow.edges[1]!, type: 'conditional', condition: 'record.name ==' } as typeof flow.edges[number];
1085+
return flow;
1086+
};
1087+
1088+
/** The per-item door's 422 for a draft of `body`, staged as `stageLegacy` stages it. */
1089+
async function perItemRefusal(body: ReturnType<typeof fetchFlow>) {
1090+
const { engine } = makeStubEngine();
1091+
const protocol = new ObjectStackProtocolImplementation(engine);
1092+
await seedActive(engine, body);
1093+
await stageDraft(protocol, 'flow', body);
1094+
const err: any = await protocol.publishMetaItem({ type: 'flow', name: body.name, packageId: PKG } as any)
1095+
.then(() => null, (e: unknown) => e);
1096+
return { protocol, err };
1097+
}
1098+
1099+
const issueTriples = (issues: Array<{ rule?: string; path?: string; message: string }>) =>
1100+
issues.map((i) => [i.rule, i.path, i.message]).sort();
1101+
1102+
it('a draft still refused once converted is refused at both doors with the issues its canonical spelling gets', async () => {
1103+
const canonical = await perItemRefusal(brokenEdge('broken_fetch', 'filter'));
1104+
const legacy = await perItemRefusal(brokenEdge('broken_fetch', 'filters'));
1105+
1106+
expect({ code: canonical.err?.code, status: canonical.err?.status }).toEqual({ code: 'INVALID_METADATA', status: 422 });
1107+
expect(canonical.err.issues.map((i: any) => i.rule)).toContain('expression-invalid');
1108+
expect({ code: legacy.err?.code, status: legacy.err?.status }).toEqual({ code: 'INVALID_METADATA', status: 422 });
1109+
expect(issueTriples(legacy.err.issues)).toEqual(issueTriples(canonical.err.issues));
1110+
1111+
// The package door refuses the same draft, on the same issues, and
1112+
// publishes nothing.
1113+
const batch: any = await legacy.protocol.publishPackageDrafts({ packageId: PKG });
1114+
expect(batch).toMatchObject({ success: false, publishedCount: 0 });
1115+
expect(batch.failed.map((f: any) => f.name)).toEqual(['broken_fetch']);
1116+
expect(issueTriples(batch.failed[0].issues)).toEqual(issueTriples(canonical.err.issues));
1117+
});
1118+
});

0 commit comments

Comments
 (0)