From d6909432614bf5d92607f965a3b98d1a89d3726b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 02:39:00 +0000 Subject: [PATCH 1/3] fix(metadata-protocol): the layered read of a shipped flow name reports the loader's body as the effective layer For a flow name the loader ships, getMetaItemLayered now decides its effective layer with the stored-row predicate the list and the by-name read already call (isShippedFlowName): the code layer is effective, and a stored row of that name stays in the overlay layer as a shadowed layer of its own scope. Every other type, and a flow name no package ships, keeps overlay-wins. Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB Co-authored-by: Claude --- ...protocol.flow-layered-shipped-name.test.ts | 303 ++++++++++++++++++ packages/metadata-protocol/src/protocol.ts | 31 +- 2 files changed, 332 insertions(+), 2 deletions(-) create mode 100644 packages/metadata-protocol/src/protocol.flow-layered-shipped-name.test.ts diff --git a/packages/metadata-protocol/src/protocol.flow-layered-shipped-name.test.ts b/packages/metadata-protocol/src/protocol.flow-layered-shipped-name.test.ts new file mode 100644 index 00000000000..459a8adfa39 --- /dev/null +++ b/packages/metadata-protocol/src/protocol.flow-layered-shipped-name.test.ts @@ -0,0 +1,303 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#21002, #20761 ruling rule 1, ADR-0126 §2 / §3] The layered read of a flow + * name the loader's set holds reports the loader's body as the effective layer + * — the body the by-name read and the list serve for that name — and a stored + * row of that name as a shadowed layer, never as the effective layer under the + * package's lock and provenance flags. + * + * `flow` is Regime C: the packaged base is locked, "⛔ Never silent override, + * never an overlay read path". Since #20913 the flattened view and since + * #20946 `getMetaItem` apply that by name, with the stored-row predicate + * (`isShippedFlowName`). `getMetaItemLayered` computed its effective layer as + * overlay-wins, so for such a name it answered the stored body while its + * docblock promised "what `getMetaItem` would return". It now decides the + * effective layer with the same predicate, and keeps the row in `overlay`. + * + * The registry double is the one `protocol.flow-by-name-shipped-name.test.ts` + * uses: the real `SchemaRegistry`'s key shape (`:` for a loader + * entry, the bare name for a hydrated row), its `getItem` precedence (the bare + * slot first) and its artifact lookup (package-scoped code-artifact entries + * first). `@objectstack/objectql` cannot be imported here: it depends on this + * package. The cold-boot proof over the real composition is + * `flow-shipped-name-layered-read.dogfood.test.ts`. + * + * Controls: a flow name no managed package ships keeps its stored row as the + * effective layer, a shipped name with no stored row is unchanged, an + * organization-scoped flow row is out of the read's reach, and a type in the + * overlay regime keeps overlay-wins. + */ +import { describe, expect, it } from 'vitest'; +import { assertEngineFindOnePredicate, isCodeArtifactBody } from '@objectstack/metadata-core'; +import { ObjectStackProtocolImplementation } from './protocol.js'; + +const PACKAGE_ID = 'com.example.pkg'; +const SHIPPED = 'pkg_flow'; +const CUSTOMER = 'customer_flow'; +const ORG_ID = 'org_a'; + +const flowBody = (name: string, label: string, extra: Record = {}) => ({ + name, + label, + type: 'autolaunched', + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { id: label === 'LOADER' ? 'end' : 'stored_end', type: 'end', label: 'End' }, + ], + edges: [{ id: 'e1', source: 'start', target: label === 'LOADER' ? 'end' : 'stored_end' }], + ...extra, +}); + +interface StoredRow { + id: string; + type: string; + name: string; + organization_id: string | null; + package_id: string | null; + state: string; + metadata: string; +} + +const storedRow = (type: string, name: string, body: unknown, extra: Partial = {}): StoredRow => ({ + id: `r_${type}_${name}_${extra.organization_id ?? 'env'}`, + type, + name, + organization_id: null, + package_id: null, + state: 'active', + metadata: JSON.stringify(body), + ...extra, +}); + +/** + * A registry double with the real `SchemaRegistry`'s two key shapes, its + * `getItem` precedence and its artifact lookup. `registerItem` stamps a package + * entry the way `applyProtection` does and leaves a bare registration's + * provenance alone. + */ +function registryDouble() { + const byType = new Map>>(); + const collection = (type: string) => { + if (!byType.has(type)) byType.set(type, new Map()); + return byType.get(type)!; + }; + return { + registerItem(type: string, item: Record, keyField = 'name', packageId?: string) { + const name = String(item[keyField]); + if (packageId) { + if (item._packageId === undefined) item._packageId = packageId; + if (item._provenance === undefined) item._provenance = 'package'; + collection(type).set(`${packageId}:${name}`, item); + } else { + collection(type).set(name, item); + } + }, + listItems(type: string, packageId?: string) { + const all = [...(byType.get(type)?.values() ?? [])]; + return packageId ? all.filter((it) => it._packageId === packageId) : all; + }, + /** The real precedence: the bare slot, then prefer-local, then the first composite. */ + getItem(type: string, name: string, packageId?: string) { + const entries = byType.get(type); + if (!entries) return undefined; + const direct = entries.get(name); + if (direct) return direct; + if (packageId) { + const local = entries.get(`${packageId}:${name}`); + if (local) return local; + } + for (const [key, item] of entries) if (key.endsWith(`:${name}`)) return item; + return undefined; + }, + getArtifactItem(type: string, name: string, packageId?: string) { + const entries = [...(byType.get(type)?.entries() ?? [])]; + const scoped = entries.filter(([key, it]) => key.endsWith(`:${name}`) && isCodeArtifactBody(it)); + const local = packageId ? scoped.find(([, it]) => it._packageId === packageId) : undefined; + if (local) return local[1]; + if (scoped[0]) return scoped[0][1]; + const bare = byType.get(type)?.get(name); + return bare && isCodeArtifactBody(bare) ? bare : undefined; + }, + bare(type: string, name: string) { + return byType.get(type)?.get(name); + }, + getObject: () => undefined, + registerObject: () => undefined, + getPackage: () => undefined, + isPackageDisabled: () => false, + applyNavContributions: (app: unknown) => app, + }; +} + +/** + * The engine double: `find` / `findOne` over `sys_metadata` rows, plus the + * registry. ⛔ No `insert` / `update` / `delete` — the read paths under test + * issue no write verb. + */ +function harness(rows: StoredRow[]) { + const registry = registryDouble(); + // What the loader registered: one packaged flow and one packaged view. + registry.registerItem('flow', flowBody(SHIPPED, 'LOADER'), 'name', PACKAGE_ID); + registry.registerItem('view', { name: 'pkg_view', label: 'LOADER VIEW' }, 'name', PACKAGE_ID); + const matching = (where: Record) => { + for (const k of Object.keys(where)) { + if (k.startsWith('$')) throw new Error(`[test double] unsupported WHERE combinator '${k}'`); + } + return rows.filter((r) => + Object.entries(where).every(([k, v]) => v === undefined || (r as unknown as Record)[k] === v), + ); + }; + const engine: any = { + async find(table: string, opts?: { where?: Record; limit?: number }) { + if (table !== 'sys_metadata') return []; + const matched = matching(opts?.where ?? {}); + // `check:objectql-double-limit` — the caller's bound, applied after the filter. + return opts?.limit === undefined ? matched : matched.slice(0, opts.limit); + }, + async findOne(table: string, opts?: { where?: Record }) { + // `check:engine-double-contract` — refuses what the real engine refuses. + assertEngineFindOnePredicate(table, opts); + if (table !== 'sys_metadata') return null; + return matching(opts?.where ?? {})[0] ?? null; + }, + registry, + }; + const protocol = new ObjectStackProtocolImplementation(engine, () => new Map()); + return { protocol, registry }; +} + +type Layer = Record & { label?: unknown; nodes?: unknown }; +interface LayeredAnswer { + code: Layer | null; + overlay: Layer | null; + overlayScope: 'org' | 'env' | null; + effective: Layer | null; + packageId?: string; + provenance?: string; +} + +const layered = async (protocol: ObjectStackProtocolImplementation, request: Record) => + (await protocol.getMetaItemLayered(request as any)) as unknown as LayeredAnswer; +const byName = async (protocol: ObjectStackProtocolImplementation, request: Record) => + ((await protocol.getMetaItem(request as any)) as { item: Layer }).item; +const listed = async (protocol: ObjectStackProtocolImplementation, name: string) => + ((await protocol.getMetaItems({ type: 'flow' })) as { items: Layer[] }).items + .filter((it) => it.name === name); + +describe('[#21002] the layered read of a shipped flow name reports the loader\'s body as the effective layer', () => { + it('a shipped name with an environment-wide stored row: the effective layer is the loader\'s body, and the row is a shadowed layer', async () => { + const { protocol } = harness([storedRow('flow', SHIPPED, flowBody(SHIPPED, 'STORED'))]); + + const answer = await layered(protocol, { type: 'flow', name: SHIPPED }); + + expect(answer.effective?.label).toBe('LOADER'); + expect(answer.code?.label).toBe('LOADER'); + // The stored row is still reported, as stored, with its own scope. + expect(answer.overlay?.label).toBe('STORED'); + expect(answer.overlayScope).toBe('env'); + // The package's flags describe the effective layer, which is the loader's. + expect(answer.packageId).toBe(PACKAGE_ID); + expect(answer.provenance).toBe('package'); + }); + + it('it does so after the row was hydrated — the registry\'s bare copy of the row stands in for neither layer', async () => { + const { protocol, registry } = harness([storedRow('flow', SHIPPED, flowBody(SHIPPED, 'STORED'))]); + await protocol.getMetaItemsForExecution({ type: 'flow' }); // hydrates the row under the bare key + expect(registry.bare('flow', SHIPPED)?.label).toBe('STORED'); + + const answer = await layered(protocol, { type: 'flow', name: SHIPPED }); + + expect(answer.effective?.label).toBe('LOADER'); + expect(answer.code?.label).toBe('LOADER'); + expect(answer.overlay?.label).toBe('STORED'); + }); + + it('the layered read, the by-name read and the list answer one and the same body', async () => { + const { protocol } = harness([storedRow('flow', SHIPPED, flowBody(SHIPPED, 'STORED'))]); + + const answer = await layered(protocol, { type: 'flow', name: SHIPPED }); + const served = await byName(protocol, { type: 'flow', name: SHIPPED }); + const list = await listed(protocol, SHIPPED); + + expect(list.map((it) => it.label)).toEqual(['LOADER']); + expect(answer.effective?.label).toBe(served.label); + expect(answer.effective?.nodes).toEqual(served.nodes); + expect(answer.effective?.nodes).toEqual(list[0].nodes); + }); + + it('the package-scoped read and the plural type spelling answer the same', async () => { + for (const request of [ + { type: 'flow', name: SHIPPED, packageId: PACKAGE_ID }, + { type: 'flows', name: SHIPPED }, + ]) { + const { protocol } = harness([storedRow('flow', SHIPPED, flowBody(SHIPPED, 'STORED'))]); + const answer = await layered(protocol, request); + expect(answer.effective?.label).toBe('LOADER'); + expect(answer.overlay?.label).toBe('STORED'); + } + }); + + it('a row bound to the shipping package, or one whose own body claims its provenance, is judged by name alone', async () => { + for (const row of [ + storedRow('flow', SHIPPED, flowBody(SHIPPED, 'STORED'), { package_id: PACKAGE_ID }), + storedRow('flow', SHIPPED, flowBody(SHIPPED, 'STORED', { _packageId: PACKAGE_ID, _provenance: 'package' })), + ]) { + for (const request of [ + { type: 'flow', name: SHIPPED }, + { type: 'flow', name: SHIPPED, packageId: PACKAGE_ID }, + ]) { + const { protocol } = harness([row]); + const answer = await layered(protocol, request); + expect(answer.effective?.label).toBe('LOADER'); + expect(answer.overlay?.label).toBe('STORED'); + } + } + }); + + it('control: a flow name no managed package ships keeps its stored row as the effective layer', async () => { + const { protocol } = harness([storedRow('flow', CUSTOMER, flowBody(CUSTOMER, 'CUSTOMER'))]); + + const answer = await layered(protocol, { type: 'flow', name: CUSTOMER }); + + expect(answer.effective?.label).toBe('CUSTOMER'); + expect(answer.overlay?.label).toBe('CUSTOMER'); + expect(answer.overlayScope).toBe('env'); + }); + + it('control: a shipped name with no stored row is unchanged', async () => { + const { protocol } = harness([]); + + const answer = await layered(protocol, { type: 'flow', name: SHIPPED }); + + expect(answer.effective?.label).toBe('LOADER'); + expect(answer.overlay).toBeNull(); + expect(answer.overlayScope).toBeNull(); + }); + + it('control: an organization-scoped flow row is out of the read\'s reach', async () => { + const { protocol } = harness([ + storedRow('flow', SHIPPED, flowBody(SHIPPED, 'ORG ROW'), { organization_id: ORG_ID }), + storedRow('flow', CUSTOMER, flowBody(CUSTOMER, 'ORG ROW'), { organization_id: ORG_ID }), + ]); + + const shipped = await layered(protocol, { type: 'flow', name: SHIPPED, organizationId: ORG_ID }); + expect(shipped.effective?.label).toBe('LOADER'); + expect(shipped.overlay).toBeNull(); + expect(shipped.overlayScope).toBeNull(); + + const customer = await layered(protocol, { type: 'flow', name: CUSTOMER, organizationId: ORG_ID }); + expect(customer.overlay).toBeNull(); + expect(customer.effective).toBeNull(); + }); + + it('control: an overlay of a packaged type in the overlay regime is still the effective layer', async () => { + const { protocol } = harness([storedRow('view', 'pkg_view', { name: 'pkg_view', label: 'OVERLAY VIEW' })]); + + const answer = await layered(protocol, { type: 'view', name: 'pkg_view' }); + + expect(answer.effective?.label).toBe('OVERLAY VIEW'); + expect(answer.code?.label).toBe('LOADER VIEW'); + expect(answer.overlay?.label).toBe('OVERLAY VIEW'); + }); +}); diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index b05f7bce46d..6056394ec7a 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -9041,7 +9041,10 @@ export class ObjectStackProtocolImplementation implements * Phase 3a-layered-get: return the 3 layers of a metadata item * separately — `code` (artifact-loaded baseline), `overlay` (per-org * customisation row, if any), and `effective` (what `getMetaItem` - * would return, i.e. overlay-wins merge). + * would return, i.e. overlay-wins merge — except for a flow name the + * loader ships, where `getMetaItem` serves the loader's body, so + * `effective` is the code layer and a stored row of that name is + * reported in `overlay` as a shadowed layer, #21002). * * Drives the "Code default vs Overlay vs Effective" diff tab in the * generic Metadata Resource Edit page. Admins can see exactly what @@ -9375,7 +9378,31 @@ export class ObjectStackProtocolImplementation implements // the tenant actually stored: a code-declared extension is not a tenant // customisation, and #7556 drew that boundary deliberately (the same // reason `governServedItem` is called here and never on `overlay`). - const effectiveBase: unknown | null = overlay !== null + // + // ── [#21002, #20761 ruling rule 1, ADR-0126 §2 / §3] A shipped FLOW name ── + // + // `flow` is Regime C: the packaged base is locked, "⛔ Never silent + // override, never an overlay read path". For a flow name the loader's + // set holds, the flattened view (#20913) and {@link getMetaItem} + // (#20946) serve the loader's body, and a stored row of that name is + // neither merged into the package's slot nor lets it stand in for it. + // `effective` is "what `getMetaItem` would return", so for such a name + // it is the CODE layer, decided by the SAME stored-row predicate, + // {@link isShippedFlowName}, judged by NAME — ⛔ no fourth precedence + // path of this method's own. Overlay-wins here served the stored body + // as the effective layer while the lock and provenance flags (resolved + // from `code` first, below) named the package. + // + // The stored row is still REPORTED: `overlay` / `overlayScope` keep the + // row as stored, a shadowed layer of its own scope beside the effective + // one, never the effective layer itself. The code layer needs no + // registry-half call ({@link isStoredFlowEntryOfShippedName}): it reads + // the loader's set (`lookupArtifactItem`, blind to tenant-authored rows) + // before the registry's bare slot, and a shipped name is by definition + // one that set holds. Every other type, and a flow name no managed + // package ships, keeps overlay-wins. What becomes of the stored rows + // themselves (keep, refuse, migrate) is not decided here. + const effectiveBase: unknown | null = overlay !== null && !this.isShippedFlowName(request.type, request.name) ? this.foldObjectExtendersFromRegistry(request.type, request.name, overlay) : code; const effective: unknown | null = this.governServedObject(request.type, effectiveBase); From 5cdb27e44d3723aa8c3a0d610b281e4b5c14870b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 02:40:57 +0000 Subject: [PATCH 2/3] test(dogfood): the layered, by-name and list reads of a shipped flow name agree across a cold boot Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB Co-authored-by: Claude --- ...-shipped-name-layered-read.dogfood.test.ts | 308 ++++++++++++++++++ 1 file changed, 308 insertions(+) create mode 100644 packages/qa/dogfood/test/flow-shipped-name-layered-read.dogfood.test.ts diff --git a/packages/qa/dogfood/test/flow-shipped-name-layered-read.dogfood.test.ts b/packages/qa/dogfood/test/flow-shipped-name-layered-read.dogfood.test.ts new file mode 100644 index 00000000000..734b486941f --- /dev/null +++ b/packages/qa/dogfood/test/flow-shipped-name-layered-read.dogfood.test.ts @@ -0,0 +1,308 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#21002, #20761 ruling rule 1, ADR-0126 §2, ADR-0131 D6] For a flow name the +// loader ships from a managed package, the LAYERED read reports the loader's +// body as the effective layer — the body the by-name read and the list answer +// for that name — and a stored row of that name as a shadowed layer, never as +// the effective layer under the package's lock and provenance flags. Over the +// real showcase composition, on a database file. +// +// ## What was broken +// +// Since #20913 the flattened flow view (the list door and the execution view) +// serves a shipped name from the loader's entries alone, and since #20946 the +// by-name read does too. The layered read did not: its effective layer was +// "overlay wins", so `GET /meta/flow/NAME/layers` answered the stored body as +// the effective definition while its lock and provenance flags named the +// package — and its own docblock promises the effective layer is "what +// `getMetaItem` would return". Three read doors, two answers about one name. +// +// ## Why a booted stack, over two boots +// +// The unit pins (`protocol.flow-layered-shipped-name.test.ts`) hold the read's +// branches with doubles. What they cannot answer is the composition: that the +// layered door's real sources — the stored row, the metadata service and the +// registry the boot hydration filled — resolve the effective layer to the +// loader's body once the row is at rest. A stored row is only read back by a +// cold boot, so the stack is booted twice on one file. +// +// ## What each case pins (triage's pins, plus the controls) +// +// - the layered read of a shipped name with a stored row: the effective layer +// is the loader's body, and the stored row is still reported, as a shadowed +// layer of its own scope, under the package's flags; +// - the layered read, the by-name read and the list agree on that body; +// - the deprecated layers flag on the by-name door answers the same; +// - a shipped name with no stored row is unchanged (control); +// - an organization-scoped row stays out of reach (control); +// - a name no managed package ships keeps its stored row as the effective +// layer (control). +// +// Not pinned here: the published-snapshot door. It reads the layered answer but +// picks its layer by itself, so it does not follow the effective layer; what it +// answers for a shipped name is left to the card's decision. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import showcaseStack from '@objectstack/example-showcase'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { RecordChangeTriggerPlugin } from '@objectstack/trigger-record-change'; +import { ConnectorRestPlugin } from '@objectstack/connector-rest'; +import { ConnectorOpenApiPlugin } from '@objectstack/connector-openapi'; +import { ConnectorMcpPlugin } from '@objectstack/connector-mcp'; +import { fileURLToPath } from 'node:url'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +/** Package-relative connector refs resolve against the cwd — see the sibling boots. */ +const SHOWCASE_DIR = fileURLToPath(new URL('../../../../examples/app-showcase/', import.meta.url)); + +/** The package the showcase composition loads its flows from. */ +const SHOWCASE_PACKAGE = 'com.example.showcase'; +/** A shipped flow given an environment-wide stored row of its name: the subject. */ +const SUBJECT = 'showcase_urgent_task_alert'; +/** A shipped flow given no stored row at all: the control. */ +const CONTROL = 'showcase_task_completed'; +/** A shipped flow given an ORGANIZATION-scoped row only. */ +const ORG_SUBJECT = 'showcase_task_assigned_notify'; +const ORG_ID = 'org_dogfood_21002'; +/** A shipped screen flow whose body seeds the customer flow below (no trigger). */ +const CUSTOMER_SOURCE = 'showcase_reassign_wizard'; +/** A flow name no managed package ships, given an environment-wide stored row. */ +const CUSTOMER = 'dogfood_21002_customer_flow'; + +/** The node id and label a stored body carries, so it can be told from the loader's. */ +const STORED_NODE = 'stored_node_21002'; +const STORED_LABEL = 'Stored body 21002'; +const CUSTOMER_LABEL = 'Customer flow 21002'; + +const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] }; + +interface FlowBody { + name?: string; + label?: string; + nodes?: Array<{ id: string }>; +} +interface Layered { + code?: FlowBody | null; + overlay?: FlowBody | null; + overlayScope?: 'org' | 'env' | null; + effective?: FlowBody | null; + provenance?: string; + packageId?: string; + lock?: string; +} +interface Engine { + getFlow(name: string): Promise; + packagedFlowOwner(name: string): string | undefined; +} +interface Ql { + insert(object: string, data: Record, options?: unknown): Promise; + find(object: string, options?: unknown): Promise>>; +} + +const plugins = () => [ + new RecordChangeTriggerPlugin(), + new ConnectorRestPlugin(), + new ConnectorOpenApiPlugin(), + new ConnectorMcpPlugin({ declarativeStdio: ['node'] }), +]; + +async function boot(databaseFile: string): Promise { + return bootStack(showcaseStack, { automation: true, databaseFile, extraPlugins: plugins() }); +} + +const nodeIds = (flow: FlowBody | null | undefined) => (flow?.nodes ?? []).map((n) => n.id); + +/** A copy of a loader's body with every underscore-prefixed key dropped. */ +function plainCopy(loader: FlowBody | null): Record { + const body: Record = JSON.parse(JSON.stringify(loader)); + for (const key of Object.keys(body)) if (key.startsWith('_')) delete body[key]; + return body; +} + +/** The loader's body, as a distinguishable stored body: a new label, one node renamed. */ +function storedBodyFrom(loader: FlowBody | null): Record { + const body = plainCopy(loader); + body.label = STORED_LABEL; + const nodes = body.nodes as Array<{ id: string }>; + const edges = body.edges as Array<{ source: string; target: string }>; + const from = nodes[1].id; + nodes[1].id = STORED_NODE; + for (const edge of edges) { + if (edge.source === from) edge.source = STORED_NODE; + if (edge.target === from) edge.target = STORED_NODE; + } + return body; +} + +describe('the layered flow read agrees with the by-name read and the list for a shipped name across a cold boot (showcase)', () => { + let stack: VerifyStack; + let token: string; + let prevCwd: string; + let dir: string; + let dbFile: string; + const loader: Record = {}; + + const engine = () => stack.kernel.getServiceAsync('automation') as unknown as Promise; + const ql = () => stack.kernel.getServiceAsync('objectql') as unknown as Promise; + + const call = async (path: string) => { + const res = await stack.apiAs(token, 'GET', path); + const json: unknown = await res.json().catch(() => ({})); + return { status: res.status, json }; + }; + const unwrap = (json: unknown) => { + const body = json as Record; + return (body?.data ?? body) as Record; + }; + /** `GET /meta/flow/:name/layers` — the three-layer diagnostic. */ + const layers = async (name: string, path = `/meta/flow/${name}/layers`) => { + const read = await call(path); + return { status: read.status, doc: unwrap(read.json) as Layered }; + }; + /** `GET /meta/flow/:name` — the served document, unwrapped from whichever envelope carries it. */ + const byName = async (name: string) => { + const read = await call(`/meta/flow/${name}`); + const doc = unwrap(read.json); + return { status: read.status, doc: (doc?.item ?? doc) as FlowBody }; + }; + /** `GET /meta/flow` — every served entry of one name. */ + const listed = async (name: string) => { + const read = await call('/meta/flow'); + const data = unwrap(read.json) as Record | unknown[]; + const items = (Array.isArray(data) ? data : (data as { items?: unknown[] })?.items ?? []) as FlowBody[]; + return { status: read.status, entries: items.filter((it) => it?.name === name) }; + }; + + beforeAll(async () => { + prevCwd = process.cwd(); + process.chdir(SHOWCASE_DIR); + dir = mkdtempSync(join(tmpdir(), 'dogfood-21002-')); + dbFile = join(dir, 'showcase.db'); + + // First boot: read the loader's bodies, then put rows in the store. + stack = await boot(dbFile); + const first = await engine(); + const store = await ql(); + for (const name of [SUBJECT, CONTROL, ORG_SUBJECT, CUSTOMER_SOURCE]) loader[name] = await first.getFlow(name); + + const now = new Date().toISOString(); + const row = (name: string, organizationId: string | null, body: Record) => ({ + type: 'flow', + name, + organization_id: organizationId, + package_id: null, + state: 'active', + version: 1, + checksum: null, + created_at: now, + updated_at: now, + metadata: JSON.stringify(body), + }); + const customer = { ...plainCopy(loader[CUSTOMER_SOURCE]), name: CUSTOMER, label: CUSTOMER_LABEL }; + for (const data of [ + row(SUBJECT, null, storedBodyFrom(loader[SUBJECT])), + row(ORG_SUBJECT, ORG_ID, storedBodyFrom(loader[ORG_SUBJECT])), + row(CUSTOMER, null, customer), + ]) { + await store.insert('sys_metadata', data, { context: SYSTEM_CTX }); + } + await stack.stop(); + + // The measured boot: cold, on the same file. + stack = await boot(dbFile); + token = await stack.signIn(); + }, 360_000); + + afterAll(async () => { + await stack?.stop(); + if (prevCwd) process.chdir(prevCwd); + if (dir) rmSync(dir, { recursive: true, force: true }); + }); + + it('the store holds the rows the second boot read, and the loader still ships the subject', async () => { + const rows = await (await ql()).find('sys_metadata', { + where: { type: 'flow', state: 'active' }, + context: SYSTEM_CTX, + }); + const names = rows.map((r) => `${String(r.name)}@${String(r.organization_id ?? '')}`); + expect(names).toContain(`${SUBJECT}@`); + expect(names).toContain(`${ORG_SUBJECT}@${ORG_ID}`); + expect(names).toContain(`${CUSTOMER}@`); + expect((await engine()).packagedFlowOwner(SUBJECT)).toBe(SHOWCASE_PACKAGE); + expect(loader[SUBJECT]?.label).not.toBe(STORED_LABEL); + }); + + it('the layered door reports the loader\'s body as the effective layer of a shipped name with a stored row, under the package\'s flags', async () => { + const read = await layers(SUBJECT); + + expect(read.status).toBe(200); + expect(read.doc.effective?.label).toBe(loader[SUBJECT]?.label); + expect(nodeIds(read.doc.effective)).toEqual(nodeIds(loader[SUBJECT])); + expect(nodeIds(read.doc.effective)).not.toContain(STORED_NODE); + expect(nodeIds(read.doc.code)).toEqual(nodeIds(loader[SUBJECT])); + expect(read.doc.packageId).toBe(SHOWCASE_PACKAGE); + expect(read.doc.provenance).toBe('package'); + }); + + it('the stored row is still reported, as a shadowed layer of its own scope', async () => { + const read = await layers(SUBJECT); + + expect(read.doc.overlay?.label).toBe(STORED_LABEL); + expect(nodeIds(read.doc.overlay)).toContain(STORED_NODE); + expect(read.doc.overlayScope).toBe('env'); + }); + + it('the layered door, the by-name read and the list answer one and the same body', async () => { + const layered = await layers(SUBJECT); + const read = await byName(SUBJECT); + const list = await listed(SUBJECT); + + expect(read.status).toBe(200); + expect(list.status).toBe(200); + expect(list.entries).toHaveLength(1); + expect(layered.doc.effective?.label).toBe(read.doc.label); + expect(list.entries[0].label).toBe(read.doc.label); + expect(nodeIds(layered.doc.effective)).toEqual(nodeIds(read.doc)); + expect(nodeIds(list.entries[0])).toEqual(nodeIds(read.doc)); + }); + + it('the deprecated layers flag on the by-name door answers the same layers', async () => { + const read = await layers(SUBJECT, `/meta/flow/${SUBJECT}?layers=true`); + + expect(read.status).toBe(200); + expect(nodeIds(read.doc.effective)).toEqual(nodeIds(loader[SUBJECT])); + expect(nodeIds(read.doc.overlay)).toContain(STORED_NODE); + }); + + it('control: a shipped name with no stored row is unchanged', async () => { + const read = await layers(CONTROL); + + expect(read.status).toBe(200); + expect(read.doc.overlay).toBeNull(); + expect(read.doc.overlayScope).toBeNull(); + expect(nodeIds(read.doc.effective)).toEqual(nodeIds(loader[CONTROL])); + }); + + it('control: an organization-scoped row stays out of the layered door\'s reach', async () => { + const read = await layers(ORG_SUBJECT); + + expect(read.status).toBe(200); + expect(read.doc.overlay).toBeNull(); + expect(read.doc.overlayScope).toBeNull(); + expect(read.doc.effective?.label).toBe(loader[ORG_SUBJECT]?.label); + expect(nodeIds(read.doc.effective)).toEqual(nodeIds(loader[ORG_SUBJECT])); + }); + + it('control: a flow name no managed package ships keeps its stored row as the effective layer', async () => { + const read = await layers(CUSTOMER); + const byNameRead = await byName(CUSTOMER); + + expect(read.status).toBe(200); + expect(read.doc.effective?.label).toBe(CUSTOMER_LABEL); + expect(read.doc.overlay?.label).toBe(CUSTOMER_LABEL); + expect(read.doc.overlayScope).toBe('env'); + expect(byNameRead.doc.label).toBe(CUSTOMER_LABEL); + }); +}); From 39ed9ac48a71c828ed30ef77711c1707dc6dd668 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 02:46:38 +0000 Subject: [PATCH 3/3] chore(metadata-protocol): changeset for the layered flow read, and the engine-double ledger row its unit pin needs Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB Co-authored-by: Claude --- .changeset/21002-layered-flow-read-shipped-name.md | 13 +++++++++++++ scripts/engine-double-contract.pinned.json | 5 +++++ 2 files changed, 18 insertions(+) create mode 100644 .changeset/21002-layered-flow-read-shipped-name.md diff --git a/.changeset/21002-layered-flow-read-shipped-name.md b/.changeset/21002-layered-flow-read-shipped-name.md new file mode 100644 index 00000000000..485c434f7a7 --- /dev/null +++ b/.changeset/21002-layered-flow-read-shipped-name.md @@ -0,0 +1,13 @@ +--- +'@objectstack/metadata-protocol': patch +--- + +fix(metadata-protocol): the layered read of a flow name a managed package ships reports the package's flow as the effective layer, as the by-name read and the flow list do (#21002) + +Clause-②: no + +`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 by-name read, `GET /api/v1/meta/flow/:name`, already serve the package's flow for a name a managed package ships (#20913, #20946). The layered read, `GET /api/v1/meta/flow/:name/layers`, did not: for such a name it reported a stored flow of that name as the effective layer, while its lock and provenance flags named the package. So the layered read and the other two read doors answered two different flows for one name. + +The layered read now decides the effective layer with the same check the other two doors use. For a name a managed package ships, the effective layer is the package's flow, with or without a package scope, whatever the stored flow's own package binding or markings say. The stored flow is still reported, as a separate layer of its own scope that does not take effect. The deprecated layers flag on the by-name read answers the same. + +The stored flow is not deleted, rewritten or refused. Flow names no managed package ships, organization-scoped rows and every other metadata type are read as before. diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index 8c5e2b5caef..3f77837f638 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -636,6 +636,11 @@ "verb": "findOne", "pinned": 1 }, + { + "file": "packages/metadata-protocol/src/protocol.flow-layered-shipped-name.test.ts", + "verb": "findOne", + "pinned": 1 + }, { "file": "packages/metadata-protocol/src/protocol.flow-org-override-closed.test.ts", "verb": "delete",