Skip to content

Commit 25f2e64

Browse files
fix(metadata-protocol): the by-name read of a shipped flow name serves the loader's body, as the list does (#20946) (#20994)
Fixes #20946 Clause-②: no ## What For a flow name the loader ships from a managed package, the by-name read (`GET /api/v1/meta/flow/NAME`) now answers the loader's body. That is the same body the flow list (`GET /api/v1/meta/flow`) and the execution view have answered since #20913. A stored row of that name is no longer served by name as the package's definition. `getMetaItem` in `packages/metadata-protocol/src/protocol.ts` now calls the two predicates PR #20942 introduced for the list, and adds no precedence rule of its own: - **The stored-row half, `isShippedFlowName`, judged by name.** The active read does not adopt the environment-wide stored row of a shipped flow name. The row's package binding and the body's package-provenance stamps decide nothing. - **The registry half, `isStoredFlowEntryOfShippedName`.** The registry answers its bare slot first, and for a shipped flow name that slot holds the hydrated stored row. That entry is not one of the loader's, so the loader's entry is served. The predicates are called, not edited. Only `getMetaItem` moves in `protocol.ts` (+36 / -1 there). ## Why - Triage direction `5920432754` on #20946 (the interim): "the by-name read serves the loader's artifact for a shipped flow name, using the same predicates PR #20942 introduces for the list and the execution view … ⛔ No third precedence path. The by-name read calls the predicate the list calls." - ADR-0126 §2 (`flow` is Regime C): "⛔ Never silent override, never an overlay read path". ADR-0131 D6: managed definitions are sealed. - #20761's ruling `5904938166`, rule 1: a body's package-provenance stamps are display only. What becomes of the stored rows themselves (keep, refuse, migrate) belongs to #15206. This PR does not decide it. ## Repro, before and after Showcase composition on a database file, cold boot. Between two boots, a stored row was placed at rest under a shipped flow name, with a body that can be told apart from the loader's (its own label, one node renamed). Two more rows were placed: an organization-scoped row under a second shipped name, and an environment-wide row under a name no package ships. | Door or reading | `origin/main` `f6ccca4a44` | this branch | |---|---|---| | `GET /meta/flow/NAME`, shipped name with a stored row | 200, the stored body, under the package's stamps | 200, the loader's body | | the same door with a package scope | 200, the stored body | 200, the loader's body | | `GET /meta/flow`, the entry for NAME | the loader's body | the loader's body (unchanged) | | startup receipt for NAME | armed: package, shadowed: runtime | unchanged | | control: a shipped name with no stored row | the loader's body | unchanged | | control: a shipped name with an organization-scoped row only | the loader's body | unchanged | | control: an unshipped name with a stored row | the stored body | unchanged | ## Pins - **Unit:** `packages/metadata-protocol/src/protocol.flow-by-name-shipped-name.test.ts`, 10 cases. It uses a registry double with the real `SchemaRegistry` key shapes, its `getItem` precedence (the bare slot first) and its artifact lookup. - A shipped name with a stored row answers the loader's body, both before and after the row is hydrated. - By name and in the list, the shipped name answers the same body. - The package-scoped read and the plural type spelling answer the same. - A row bound to the shipping package, or one whose body claims the package's stamps, is judged by name alone. - Controls: an unshipped name keeps its stored row; a shipped name with no row is unchanged; an organization-scoped row is out of reach; an overlay-regime type keeps its overlay. - **Dogfood cold boot:** `packages/qa/dogfood/test/flow-shipped-name-by-name-read.dogfood.test.ts`, 8 cases. - By name, on both spellings of the door, the loader's body. - By name and in the list, one and the same body. - The stored row is still reported as a shadowed contender, and the loader's body is what is armed. - The three controls in the table above. - The pin is a new file. `flow-provenance-server-held.dogfood.test.ts` is not touched. ## Verification, at head `07843e6889` - `pnpm --filter @objectstack/metadata-protocol exec vitest run --maxWorkers=2` (the whole package): 195 files passed, 3 skipped; 2896 tests passed, 19 skipped. - `pnpm --filter @objectstack/metadata-protocol run typecheck`: exit 0. `tsc --listFiles` includes the new unit pin. - Dogfood, `vitest run` over four files: the new pin, PR #20942's `flow-shipped-name-stored-row-boot`, `flow-provenance-server-held` and `automation-authoring-doors-durable`. 4 files, 35 tests passed. The metadata-protocol `dist` carries the fix. - `pnpm --filter @objectstack/dogfood run typecheck`: exit 0. `--listFiles` includes the new pin. - **Red before:** the dogfood pin against the `origin/main` build of metadata-protocol gives 3 failed and 5 passed. The three failures are the by-name cases; the store check, the receipt and the controls pass. **Ablation.** The fix was committed first (`09f3a596bd`). Each leg ran through `scripts/ablation-replace.mjs`, with its anchor hit once and a blob change confirmed on disk. The unit pin imports `./protocol.js` from source, so no rebuild was involved. | Leg | What was removed | Result | |---|---|---| | A1 | the stored-row half | 6 failed, 4 passed | | A2 | the registry half | 2 failed, 8 passed: the post-hydration case and the list-agreement case | Both restores were proven: the blob equals HEAD (`5d475cd667`) and `git diff HEAD` is empty. **Derived gates.** `node scripts/pm/dispatch-gates.mjs --commands` printed 74 commands for this tree. All 74 were run, each exit code captured before any pipe. `--ran` reconciliation: 74 derived, 74 run, 0 NOT-MEASURED, 0 unrun. - On the first pass, `check:dts-closure` and `check:dual-build-cjs-loads` exited 1. Both named `@objectstack/organizations` missing `dist/index.d.ts`, a package outside this diff that was partially built in the shared local tree. After `pnpm --filter @objectstack/organizations build`, both exited 0. - The seven roster gates whose roster sits beside a path of this diff were also run, all exit 0: `check-changeset-fixed`, `check-published-list-mirrors`, `check:authz-resolver`, `check:console-injection`, `check:error-code-casing`, `check:i18n-stale-fill` and `check:published-readme-exports`. - The head is 3 commits behind `origin/main` `7fa67dada3` (formula, plugin-security, service-analytics and the PM fleet-write scripts). None of those commits touches a path of this diff. **Lint, a proven narrowing of `pnpm lint` (the repo-wide run is CI's):** 1. **Population, from eslint's own config:** of the 5 touched paths, the config matches the 3 `.ts` files. The `.md` and `.json` files answer "File ignored because no matching configuration was supplied." 2. **Count, from `--format json`:** 5 results. The 3 linted files have 0 errors and 0 warnings. 3. **Invariance:** `eslint.config.mjs` never enables type-aware linting (every `parserOptions` is `ecmaVersion` and `sourceType` only, with no `project`). The only other files it reads are `scripts/slot-lookup-baseline.json` and `scripts/query-options-erasure-baseline.json`, and this diff touches neither. So the diff cannot move the verdict on any untouched file. **NOT MEASURED locally, declared to CI:** - Test Core shards, Temporal Conformance, the full Dogfood Regression Gate and Dogfood Verify CLI. - Build Core and the workspace type-check lanes. ## Deviations 1. **`scripts/engine-double-contract.pinned.json`, one generated row.** The new unit pin's engine double has a `findOne`, so it routes through `assertEngineFindOnePredicate`. `check:engine-double-contract` then requires the coverage ledger to learn the file, and it prescribes `--write`. The diff is exactly that one row. The file is outside the claim's file list, and the gate compels it. 2. **The package-scoped spelling is changed too.** The dispatch's mechanism hypothesis listed reads with a package id as unchanged. Measured on `origin/main`, the package-scoped by-name read served the stored body as well, because the stored-row lookup falls back to the package-less row. The list applies the two predicates whatever the package scope. Leaving this spelling out would have left the defect reachable on the same door, so it follows the ruling's intent, and it is pinned in the unit and dogfood suites. 3. **Two merges of `origin/main`.** Neither had conflicts. The net delta against `main` is 5 files, +601 / -1. ## Acceptance notes - **Unchanged, and named:** - the strict draft read and the draft-preview arm (a draft is answered as a draft, never under the artifact's envelope, and the list's preview arm is equally unfiltered); - every other metadata type (both predicates gate on `flow` first); - flow names no managed package ships; - organization-scoped flow rows, which this read never reaches because `flow` declares no org override. - **The metadata-service step of the by-name read is untouched.** Measured on the showcase composition, it answers nothing for a shipped flow name, an unshipped one or a stored one. The list's own metadata-service merge is not filtered by the predicates either. - **The pending note `.changeset/20913-flow-stored-row-shipped-name.md`** ends with "The by-name read, `GET /api/v1/meta/flow/:name`, is not changed." - That stays true as that PR's own delta. This is the reading the #20942 record applied to the 20864 note's bullet 5. - This PR's note states the change in the same release. - It is not corrected here because it is outside the claim's file list. The seat may choose to correct it. - **The ADR anchor for `protocol.ts`** already lists ADR-0126. Its invariant sentence names only the list, which is still true. It could gain a by-name clause on its next touch. Carrier: none. ## Out-of-scope finding, for the seat to file - **class b · the layered read door, `GET /api/v1/meta/flow/NAME/layers`.** - **What it serves:** for a shipped flow name with a stored row, it answers its effective layer as the stored body, and the response's provenance names the package. - **Measured:** 200 both before and after this PR, on the cold boot above. - **Contract:** the method's own docblock says the effective layer is "what `getMetaItem` would return". ADR-0126 §2 says "never an overlay read path". - **Why now:** after this PR it is the one read door for that name that disagrees with the list and the by-name read. - **Remedy shape:** the same predicate, so the effective layer takes the code layer for a shipped flow name. - **Not done here:** this claim's region is `getMetaItem` only. - Dedupe words: `meta flow layers effective stored row shipped name` · `layered read effective overlay flow regime C`. --- _Generated by [Claude Code](https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent f8178ff commit 25f2e64

5 files changed

Lines changed: 601 additions & 1 deletion

File tree

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+
---
4+
5+
fix(metadata-protocol): the by-name read of a flow name a managed package ships serves the package's flow, as the flow list does (#20946)
6+
7+
Clause-②: no
8+
9+
`flow` is in ADR-0126's Regime C: a managed package's flow is sealed, and there is no overlay read path for it. The flow list, `GET /api/v1/meta/flow`, and the execution view the automation engine binds flows from already serve the package's flow for a name a managed package ships (#20913). The by-name read, `GET /api/v1/meta/flow/:name`, did not: for such a name it served a stored flow of that name, marked as the package's flow. So the two read doors answered two different flows for one name.
10+
11+
The by-name read now applies the same rule the list applies, through the same checks. For a name a managed package ships, it serves the package's flow, with or without a package scope, whatever the stored flow's own package binding or markings say.
12+
13+
The stored flow is not deleted, rewritten or refused. It stays in the store, and the automation engine still reports it as a shadowed definition at startup. Pending drafts, flow names no managed package ships, organization-scoped rows and every other metadata type are read as before.
Lines changed: 272 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,272 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#20946, #20761 ruling rule 1, ADR-0126 §2 / §3] The by-name read of a flow
5+
* name the loader's set holds serves the loader's body — the body the list
6+
* serves for that name — and never a stored row of that name under the
7+
* package's provenance.
8+
*
9+
* `flow` is Regime C: the packaged base is locked, "⛔ Never silent override,
10+
* never an overlay read path". Since #20913 the flattened view (both faces of
11+
* `getMetaItems`) applies that by name, with two predicates: the stored-row
12+
* half ({@link ObjectStackProtocolImplementation.isShippedFlowName}) and the
13+
* registry half (`isStoredFlowEntryOfShippedName`). `getMetaItem` applied
14+
* neither: it adopted the environment-wide stored row, then grafted the
15+
* artifact's protection envelope over it, so the two read doors answered two
16+
* different bodies for one name. It now calls the same two predicates.
17+
*
18+
* The registry double keeps the real `SchemaRegistry`'s key shape
19+
* (`<package>:<name>` for a loader entry, the bare name for a hydrated row), its
20+
* `getItem` precedence (the bare slot first) and its artifact lookup
21+
* (package-scoped code-artifact entries first). `@objectstack/objectql` cannot
22+
* be imported here: it depends on this package. The cold-boot proof over the
23+
* real composition is `flow-shipped-name-by-name-read.dogfood.test.ts`.
24+
*
25+
* Controls: a flow name no managed package ships keeps its stored row (the
26+
* tenant's own flow), a shipped name with no stored row is unchanged, an
27+
* organization-scoped flow row is out of the read's reach, and a type in the
28+
* overlay regime keeps its overlay.
29+
*/
30+
import { describe, expect, it } from 'vitest';
31+
import { assertEngineFindOnePredicate, isCodeArtifactBody } from '@objectstack/metadata-core';
32+
import { ObjectStackProtocolImplementation } from './protocol.js';
33+
34+
const PACKAGE_ID = 'com.example.pkg';
35+
const SHIPPED = 'pkg_flow';
36+
const CUSTOMER = 'customer_flow';
37+
const ORG_ID = 'org_a';
38+
39+
const flowBody = (name: string, label: string, extra: Record<string, unknown> = {}) => ({
40+
name,
41+
label,
42+
type: 'autolaunched',
43+
nodes: [
44+
{ id: 'start', type: 'start', label: 'Start' },
45+
{ id: 'end', type: 'end', label: 'End' },
46+
],
47+
edges: [{ id: 'e1', source: 'start', target: 'end' }],
48+
...extra,
49+
});
50+
51+
interface StoredRow {
52+
id: string;
53+
type: string;
54+
name: string;
55+
organization_id: string | null;
56+
package_id: string | null;
57+
state: string;
58+
metadata: string;
59+
}
60+
61+
const storedRow = (type: string, name: string, body: unknown, extra: Partial<StoredRow> = {}): StoredRow => ({
62+
id: `r_${type}_${name}_${extra.organization_id ?? 'env'}`,
63+
type,
64+
name,
65+
organization_id: null,
66+
package_id: null,
67+
state: 'active',
68+
metadata: JSON.stringify(body),
69+
...extra,
70+
});
71+
72+
/**
73+
* A registry double with the real `SchemaRegistry`'s two key shapes, its
74+
* `getItem` precedence and its artifact lookup. `registerItem` stamps a package
75+
* entry the way `applyProtection` does and leaves a bare registration's
76+
* provenance alone.
77+
*/
78+
function registryDouble() {
79+
const byType = new Map<string, Map<string, Record<string, unknown>>>();
80+
const collection = (type: string) => {
81+
if (!byType.has(type)) byType.set(type, new Map());
82+
return byType.get(type)!;
83+
};
84+
return {
85+
registerItem(type: string, item: Record<string, unknown>, keyField = 'name', packageId?: string) {
86+
const name = String(item[keyField]);
87+
if (packageId) {
88+
if (item._packageId === undefined) item._packageId = packageId;
89+
if (item._provenance === undefined) item._provenance = 'package';
90+
collection(type).set(`${packageId}:${name}`, item);
91+
} else {
92+
collection(type).set(name, item);
93+
}
94+
},
95+
listItems(type: string, packageId?: string) {
96+
const all = [...(byType.get(type)?.values() ?? [])];
97+
return packageId ? all.filter((it) => it._packageId === packageId) : all;
98+
},
99+
/** The real precedence: the bare slot, then prefer-local, then the first composite. */
100+
getItem(type: string, name: string, packageId?: string) {
101+
const entries = byType.get(type);
102+
if (!entries) return undefined;
103+
const direct = entries.get(name);
104+
if (direct) return direct;
105+
if (packageId) {
106+
const local = entries.get(`${packageId}:${name}`);
107+
if (local) return local;
108+
}
109+
for (const [key, item] of entries) if (key.endsWith(`:${name}`)) return item;
110+
return undefined;
111+
},
112+
getArtifactItem(type: string, name: string, packageId?: string) {
113+
const entries = [...(byType.get(type)?.entries() ?? [])];
114+
const scoped = entries.filter(([key, it]) => key.endsWith(`:${name}`) && isCodeArtifactBody(it));
115+
const local = packageId ? scoped.find(([, it]) => it._packageId === packageId) : undefined;
116+
if (local) return local[1];
117+
if (scoped[0]) return scoped[0][1];
118+
const bare = byType.get(type)?.get(name);
119+
return bare && isCodeArtifactBody(bare) ? bare : undefined;
120+
},
121+
bare(type: string, name: string) {
122+
return byType.get(type)?.get(name);
123+
},
124+
getObject: () => undefined,
125+
registerObject: () => undefined,
126+
getPackage: () => undefined,
127+
isPackageDisabled: () => false,
128+
applyNavContributions: (app: unknown) => app,
129+
};
130+
}
131+
132+
/**
133+
* The engine double: `find` / `findOne` over `sys_metadata` rows, plus the
134+
* registry. ⛔ No `insert` / `update` / `delete` — the read paths under test
135+
* issue no write verb.
136+
*/
137+
function harness(rows: StoredRow[]) {
138+
const registry = registryDouble();
139+
// What the loader registered: one packaged flow and one packaged view.
140+
registry.registerItem('flow', flowBody(SHIPPED, 'LOADER'), 'name', PACKAGE_ID);
141+
registry.registerItem('view', { name: 'pkg_view', label: 'LOADER VIEW' }, 'name', PACKAGE_ID);
142+
const matching = (where: Record<string, unknown>) => {
143+
for (const k of Object.keys(where)) {
144+
if (k.startsWith('$')) throw new Error(`[test double] unsupported WHERE combinator '${k}'`);
145+
}
146+
return rows.filter((r) =>
147+
Object.entries(where).every(([k, v]) => v === undefined || (r as unknown as Record<string, unknown>)[k] === v),
148+
);
149+
};
150+
const engine: any = {
151+
async find(table: string, opts?: { where?: Record<string, unknown>; limit?: number }) {
152+
if (table !== 'sys_metadata') return [];
153+
const matched = matching(opts?.where ?? {});
154+
// `check:objectql-double-limit` — the caller's bound, applied after the filter.
155+
return opts?.limit === undefined ? matched : matched.slice(0, opts.limit);
156+
},
157+
async findOne(table: string, opts?: { where?: Record<string, unknown> }) {
158+
// `check:engine-double-contract` — refuses what the real engine refuses.
159+
assertEngineFindOnePredicate(table, opts);
160+
if (table !== 'sys_metadata') return null;
161+
return matching(opts?.where ?? {})[0] ?? null;
162+
},
163+
registry,
164+
};
165+
const protocol = new ObjectStackProtocolImplementation(engine, () => new Map());
166+
return { protocol, registry };
167+
}
168+
169+
const byName = async (protocol: ObjectStackProtocolImplementation, request: Record<string, unknown>) =>
170+
((await protocol.getMetaItem(request as any)) as { item: Record<string, unknown> }).item;
171+
const listed = async (protocol: ObjectStackProtocolImplementation, name: string) =>
172+
((await protocol.getMetaItems({ type: 'flow' })) as { items: Array<Record<string, unknown>> }).items
173+
.filter((it) => it.name === name);
174+
175+
describe('[#20946] the by-name read of a shipped flow name serves the loader\'s body', () => {
176+
it('a shipped name with an environment-wide stored row answers the loader\'s body, not the row\'s', async () => {
177+
const { protocol } = harness([storedRow('flow', SHIPPED, flowBody(SHIPPED, 'STORED'))]);
178+
179+
const served = await byName(protocol, { type: 'flow', name: SHIPPED });
180+
181+
expect(served.label).toBe('LOADER');
182+
expect(served._packageId).toBe(PACKAGE_ID);
183+
});
184+
185+
it('it does so after the row was hydrated — the registry\'s bare copy of the row does not stand in either', async () => {
186+
const { protocol, registry } = harness([storedRow('flow', SHIPPED, flowBody(SHIPPED, 'STORED'))]);
187+
await protocol.getMetaItemsForExecution({ type: 'flow' }); // hydrates the row under the bare key
188+
expect(registry.bare('flow', SHIPPED)?.label).toBe('STORED');
189+
190+
const served = await byName(protocol, { type: 'flow', name: SHIPPED });
191+
192+
expect(served.label).toBe('LOADER');
193+
});
194+
195+
it('by name and in the list, the shipped name answers the same body', async () => {
196+
const { protocol } = harness([storedRow('flow', SHIPPED, flowBody(SHIPPED, 'STORED'))]);
197+
198+
const list = await listed(protocol, SHIPPED);
199+
const served = await byName(protocol, { type: 'flow', name: SHIPPED });
200+
201+
expect(list.map((it) => it.label)).toEqual(['LOADER']);
202+
expect(served.label).toBe(list[0].label);
203+
expect(served.nodes).toEqual(list[0].nodes);
204+
});
205+
206+
it('the package-scoped read answers the loader\'s body too', async () => {
207+
const { protocol } = harness([storedRow('flow', SHIPPED, flowBody(SHIPPED, 'STORED'))]);
208+
209+
const served = await byName(protocol, { type: 'flow', name: SHIPPED, packageId: PACKAGE_ID });
210+
211+
expect(served.label).toBe('LOADER');
212+
});
213+
214+
it('a row bound to the shipping package, or one whose own body claims its provenance, is judged by name alone', async () => {
215+
for (const row of [
216+
storedRow('flow', SHIPPED, flowBody(SHIPPED, 'STORED'), { package_id: PACKAGE_ID }),
217+
storedRow('flow', SHIPPED, flowBody(SHIPPED, 'STORED', { _packageId: PACKAGE_ID, _provenance: 'package' })),
218+
]) {
219+
for (const request of [
220+
{ type: 'flow', name: SHIPPED },
221+
{ type: 'flow', name: SHIPPED, packageId: PACKAGE_ID },
222+
]) {
223+
const { protocol } = harness([row]);
224+
expect((await byName(protocol, request)).label).toBe('LOADER');
225+
}
226+
}
227+
});
228+
229+
it('the plural spelling of the type is folded first and answers the same', async () => {
230+
const { protocol } = harness([storedRow('flow', SHIPPED, flowBody(SHIPPED, 'STORED'))]);
231+
232+
const served = await byName(protocol, { type: 'flows', name: SHIPPED });
233+
234+
expect(served.label).toBe('LOADER');
235+
});
236+
237+
it('control: a flow name no managed package ships answers its stored row, unchanged', async () => {
238+
const { protocol } = harness([storedRow('flow', CUSTOMER, flowBody(CUSTOMER, 'CUSTOMER'))]);
239+
240+
const served = await byName(protocol, { type: 'flow', name: CUSTOMER });
241+
242+
expect(served.label).toBe('CUSTOMER');
243+
expect(isCodeArtifactBody(served)).toBe(false);
244+
});
245+
246+
it('control: a shipped name with no stored row answers the loader\'s body, unchanged', async () => {
247+
const { protocol } = harness([]);
248+
249+
const served = await byName(protocol, { type: 'flow', name: SHIPPED });
250+
251+
expect(served.label).toBe('LOADER');
252+
});
253+
254+
it('control: an organization-scoped flow row is out of the read\'s reach', async () => {
255+
const { protocol } = harness([
256+
storedRow('flow', SHIPPED, flowBody(SHIPPED, 'ORG ROW'), { organization_id: ORG_ID }),
257+
storedRow('flow', CUSTOMER, flowBody(CUSTOMER, 'ORG ROW'), { organization_id: ORG_ID }),
258+
]);
259+
260+
expect((await byName(protocol, { type: 'flow', name: SHIPPED, organizationId: ORG_ID })).label).toBe('LOADER');
261+
expect(await byName(protocol, { type: 'flow', name: CUSTOMER, organizationId: ORG_ID })).toBeUndefined();
262+
});
263+
264+
it('control: an overlay of a packaged type in the overlay regime is still served over the artifact', async () => {
265+
const { protocol } = harness([storedRow('view', 'pkg_view', { name: 'pkg_view', label: 'OVERLAY VIEW' })]);
266+
267+
const served = await byName(protocol, { type: 'view', name: 'pkg_view' });
268+
269+
expect(served.label).toBe('OVERLAY VIEW');
270+
expect(served._packageId).toBe(PACKAGE_ID);
271+
});
272+
});

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

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8676,6 +8676,33 @@ export class ObjectStackProtocolImplementation implements
86768676
// Studio's editor opens a draft buffer with `state: 'draft'`;
86778677
// runtime loaders omit it and get the live published row.
86788678
const readState: 'active' | 'draft' = request.state === 'draft' ? 'draft' : 'active';
8679+
// ── [#20946, #20761 ruling rule 1, ADR-0126 §2 / §3] A shipped FLOW name ──
8680+
//
8681+
// `flow` is Regime C: the packaged base is locked, "⛔ Never silent
8682+
// override, never an overlay read path". Since #20913 the flattened view
8683+
// ({@link readFlattenedMetaItems}, both faces) serves a flow name the
8684+
// loader's set holds from the loader's own entries alone, with two
8685+
// predicates. This read applies the SAME two, and ⛔ no third precedence
8686+
// path of its own:
8687+
// • the stored-row half, {@link isShippedFlowName}, judged by NAME —
8688+
// step 1 below does not adopt the stored row of such a name, whatever
8689+
// its package binding or the stamps its own body carries;
8690+
// • the registry half, {@link isStoredFlowEntryOfShippedName} — step 3
8691+
// does not serve the registry's bare copy of that row (the hydrated
8692+
// tenant row `getItem` answers first); the loader's entry is served.
8693+
// Adopted, the row was served with the artifact's protection envelope
8694+
// grafted over it (the merge at the end of this method): a stored body
8695+
// answered as the package's definition by name, while the list answered
8696+
// the loader's body for the same name.
8697+
//
8698+
// Scoped to the ACTIVE read. A draft is answered as a draft (the strict
8699+
// `state: 'draft'` read and the `previewDrafts` arm), never under the
8700+
// artifact's envelope, and the list's own preview arm is equally
8701+
// unfiltered. Organization-scoped flow rows never reach this read:
8702+
// `orgId` is `undefined` for `flow`, which declares no org override.
8703+
// What becomes of the stored rows themselves (keep, refuse, migrate) is
8704+
// not decided here.
8705+
const shippedFlowActiveRead = readState === 'active' && this.isShippedFlowName(request.type, request.name);
86798706

86808707
// ADR-0033 draft-overlay preview (non-strict): when the caller opts in
86818708
// (admin-gated upstream), prefer a `state='draft'` row if one exists, else
@@ -8778,7 +8805,8 @@ export class ObjectStackProtocolImplementation implements
87788805
};
87798806
const record = (orgId ? await findOverlay(orgId) : undefined)
87808807
?? await findOverlay(null);
8781-
if (record) {
8808+
// [#20946] The stored-row half — see `shippedFlowActiveRead` above.
8809+
if (record && !shippedFlowActiveRead) {
87828810
item = this.convertStoredItem(
87838811
String(record.type ?? request.type),
87848812
typeof record.metadata === 'string'
@@ -8904,6 +8932,13 @@ export class ObjectStackProtocolImplementation implements
89048932
const alt = PLURAL_TO_SINGULAR[request.type] ?? SINGULAR_TO_PLURAL[request.type];
89058933
if (alt) item = this.engine.registry.getItem(alt, request.name, request.packageId);
89068934
}
8935+
// [#20946] The registry half — see `shippedFlowActiveRead` above.
8936+
// `getItem` answers the bare slot first, and for a shipped flow name
8937+
// that slot holds the hydrated stored row, which is not one of the
8938+
// loader's entries; the loader's entry is the one the list serves.
8939+
if (this.isStoredFlowEntryOfShippedName(request.type, item)) {
8940+
item = this.lookupArtifactItem(request.type, request.name, request.packageId);
8941+
}
89078942
}
89088943

89098944
// [#5840] The MetadataService half of the #5532 rule, and the last

0 commit comments

Comments
 (0)