diff --git a/.changeset/21002-published-door-shipped-flow.md b/.changeset/21002-published-door-shipped-flow.md new file mode 100644 index 00000000000..05b71c0a10e --- /dev/null +++ b/.changeset/21002-published-door-shipped-flow.md @@ -0,0 +1,15 @@ +--- +'@objectstack/metadata-protocol': minor +'@objectstack/rest': patch +'@objectstack/runtime': patch +--- + +fix(rest,runtime): the published-snapshot read of a flow name a managed package ships answers the package's flow, as the layered read does (#21002) + +Clause-②: yes (widening) + +`flow` is in ADR-0126's Regime C: a managed package's flow is sealed, and there is no overlay read path for it. Since the previous half of #21002, the layered read, `GET /api/v1/meta/flow/:name/layers`, reports the package's flow as the effective layer for a name a managed package ships, and a stored flow of that name as a separate layer that does not take effect. The published-snapshot read, `GET /api/v1/meta/:type/:name/published`, and its runtime-dispatcher twin read that same layered answer, but served its stored layer whenever one was present. So for such a name they still answered `200` with the stored flow, not the package's. + +Both published-snapshot doors now serve the layered read's effective layer when that read put the package's flow over a stored flow, which is the package's flow. They ask the metadata protocol's own check for that decision rather than repeating it. In every other case they answer exactly as before: a flow name no managed package ships, and every other metadata type, `object` included, still answer the stored layer when one is present, and an item with no stored layer still falls through to the code/package snapshot. The stored flow is not deleted, rewritten or refused. + +**The widening.** `@objectstack/metadata-protocol` makes one existing method public: `ObjectStackProtocolImplementation.isShippedFlowName(type, name)`. It answers whether `name` is a flow name a managed package ships. It was private to the class, so a door in another package could not ask it any other way. Its answer is unchanged, and the layered read, the by-name read and the flow list keep calling it. diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 967e24b1e0d..160622e106f 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -14630,8 +14630,17 @@ export class ObjectStackProtocolImplementation implements * tenant-authored row it is, and the automation boot pull reports it as a * shadowed contender. What becomes of such rows (keep, refuse, migrate) is * not decided by this method. + * + * [#21002] PUBLIC so the published-snapshot doors can ASK it, never + * re-derive it. {@link getMetaItemLayered} decides its effective layer with + * this predicate, and `GET /meta/:type/:name/published` (the REST route + * and its dispatcher twin) reads that layered answer: when a stored row is + * present and this predicate holds for the answer's `type` and `name`, the + * effective layer — the loader's body — is what the door serves, and in + * every other case the door serves the stored row as before. One decision + * point for the three reads; the doors hold no copy of the rule. */ - private isShippedFlowName(type: string, name: unknown): boolean { + isShippedFlowName(type: string, name: unknown): boolean { if ((PLURAL_TO_SINGULAR[type] ?? type) !== 'flow') return false; if (typeof name !== 'string' || name === '') return false; return this.packagedArtifactOwner({ type: 'flow', name }) !== undefined; diff --git a/packages/qa/dogfood/test/flow-shipped-name-published-door.dogfood.test.ts b/packages/qa/dogfood/test/flow-shipped-name-published-door.dogfood.test.ts new file mode 100644 index 00000000000..589f09adc34 --- /dev/null +++ b/packages/qa/dogfood/test/flow-shipped-name-published-door.dogfood.test.ts @@ -0,0 +1,273 @@ +// 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 published-snapshot door answers the +// loader's body once a stored row of that name is at rest — the body the layered +// read reports as effective. Over the real showcase composition, on a database +// file, across a cold boot. +// +// ## What was broken +// +// Since #21002's first half the layered read decides its effective layer for a +// shipped flow name with `isShippedFlowName`: the loader's body, with the stored +// row reported beside it as a shadowed layer. The published doors read that same +// layered answer but served its stored layer whenever one was present, so they +// still answered the stored body for a sealed name — ADR-0126 §2's "never an +// overlay read path", left open on one door and its twin. +// +// ## The ruling these cases pin (triage, scoped by the decision, not by type) +// +// When the predicate decided the effective layer — the loader's body over a +// stored row — the doors serve that effective layer. In every other case they +// serve exactly what they served before. So: +// +// - a shipped flow name with a stored row: the door answers the loader's +// body, the same body the layered read reports as effective; +// - a flow name no managed package ships, with a stored row: the door still +// answers the stored body (control); +// - an `object` with a published stored row, whose effective layer differs +// from its stored layer by folding and governance (not by the predicate): +// the door still answers the stored layer, unchanged (control). +// +// ## Why only the REST door is booted here +// +// This composition serves `/meta` through the REST route alone; the +// dispatcher's `/meta` domain is reached only on hosts that mount the +// dispatcher's catch-all, which this harness does not. Its twin of this door is +// pinned with the real protocol and the real `HttpDispatcher` in +// `packages/runtime/src/domains/meta-published-runtime-publish.test.ts`. + +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 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_pub_customer_flow'; +/** A writable base and an object published into it at runtime: the `object` control. */ +const OBJECT_BASE = 'app.dogfood_21002_pub'; +const OBJECT_NAME = 'dogfood_21002_pub_widget'; + +/** The node id and label a stored body carries, so it can be told from the loader's. */ +const STORED_NODE = 'stored_node_21002_pub'; +const STORED_LABEL = 'Stored body 21002 pub'; +const CUSTOMER_LABEL = 'Customer flow 21002 pub'; +const OBJECT_LABEL = 'Widget 21002 pub'; + +const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] }; + +interface FlowBody { + name?: string; + label?: string; + nodes?: Array<{ id: string }>; +} +interface Layered { + type?: string; + name?: string; + code?: Record | null; + overlay?: Record | null; + overlayScope?: 'org' | 'env' | null; + effective?: Record | null; +} +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>>; +} +interface Protocol { + saveMetaItem(request: Record): 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: unknown) => ((flow as FlowBody | null | undefined)?.nodes ?? []).map((n) => n.id); +const labelOf = (doc: unknown) => (doc as { label?: unknown } | null | undefined)?.label; + +/** 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 published-snapshot door answers the loader\'s body for a shipped flow name with a stored row, 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; + + /** `GET /meta/:type/:name/published` on the REST route — the served document itself. */ + const restPublished = async (type: string, name: string) => { + const res = await stack.apiAs(token, 'GET', `/meta/${type}/${name}/published`); + const body: unknown = await res.json().catch(() => ({})); + return { status: res.status, doc: body as Record }; + }; + /** `GET /meta/:type/:name/layers` — the three-layer answer the door reads. */ + const layers = async (type: string, name: string) => { + const res = await stack.apiAs(token, 'GET', `/meta/${type}/${name}/layers`); + const body = (await res.json().catch(() => ({}))) as Record; + return { status: res.status, doc: (body?.data ?? body) as Layered }; + }; + + beforeAll(async () => { + prevCwd = process.cwd(); + process.chdir(SHOWCASE_DIR); + dir = mkdtempSync(join(tmpdir(), 'dogfood-21002-pub-')); + dbFile = join(dir, 'showcase.db'); + + // First boot: read the loader's bodies, put the flow rows in the store, + // and publish the control object through the runtime authoring door. + stack = await boot(dbFile); + const first = await engine(); + const store = await ql(); + for (const name of [SUBJECT, CUSTOMER_SOURCE]) loader[name] = await first.getFlow(name); + + const now = new Date().toISOString(); + const row = (name: string, body: Record) => ({ + type: 'flow', + name, + organization_id: null, + 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, storedBodyFrom(loader[SUBJECT])), row(CUSTOMER, customer)]) { + await store.insert('sys_metadata', data, { context: SYSTEM_CTX }); + } + + const protocol = await stack.kernel.getServiceAsync('protocol'); + const objectBody = { + name: OBJECT_NAME, + label: OBJECT_LABEL, + sharingModel: 'private', + fields: { title: { type: 'text', label: 'Title' } }, + }; + await protocol.saveMetaItem({ type: 'object', name: OBJECT_NAME, item: objectBody, packageId: OBJECT_BASE, mode: 'draft' }); + await protocol.saveMetaItem({ type: 'object', name: OBJECT_NAME, item: objectBody, packageId: OBJECT_BASE, mode: 'publish' }); + 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 layered read puts the loader\'s body over the stored row', async () => { + const rows = await (await ql()).find('sys_metadata', { + where: { state: 'active' }, + context: SYSTEM_CTX, + }); + const keys = rows.map((r) => `${String(r.type)}/${String(r.name)}@${String(r.organization_id ?? '')}`); + expect(keys).toContain(`flow/${SUBJECT}@`); + expect(keys).toContain(`flow/${CUSTOMER}@`); + expect(keys).toContain(`object/${OBJECT_NAME}@`); + expect((await engine()).packagedFlowOwner(SUBJECT)).toBe(SHOWCASE_PACKAGE); + expect(labelOf(loader[SUBJECT])).not.toBe(STORED_LABEL); + + // The decision the doors follow: a stored layer is present, and the + // effective layer is the loader's body, not that stored layer. + const read = await layers('flow', SUBJECT); + expect(read.status).toBe(200); + expect(nodeIds(read.doc.overlay)).toContain(STORED_NODE); + expect(nodeIds(read.doc.effective)).toEqual(nodeIds(loader[SUBJECT])); + }); + + it('the REST published door answers the loader\'s body for a shipped flow name with a stored row', async () => { + const read = await restPublished('flow', SUBJECT); + + expect(read.status).toBe(200); + expect(labelOf(read.doc)).toBe(labelOf(loader[SUBJECT])); + expect(nodeIds(read.doc)).toEqual(nodeIds(loader[SUBJECT])); + expect(nodeIds(read.doc)).not.toContain(STORED_NODE); + }); + + it('the published door serves the body the layered read reports as effective', async () => { + const layered = await layers('flow', SUBJECT); + const rest = await restPublished('flow', SUBJECT); + + expect(rest.doc).toEqual(layered.doc.effective); + }); + + it('control: a flow name no managed package ships keeps its stored body on the published door', async () => { + const layered = await layers('flow', CUSTOMER); + const rest = await restPublished('flow', CUSTOMER); + + expect(layered.doc.overlayScope).toBe('env'); + expect(rest.status).toBe(200); + expect(labelOf(rest.doc)).toBe(CUSTOMER_LABEL); + expect(rest.doc).toEqual(layered.doc.overlay); + }); + + it('control: an object\'s published stored layer is served unchanged, not its effective layer', async () => { + const layered = await layers('object', OBJECT_NAME); + const rest = await restPublished('object', OBJECT_NAME); + + expect(layered.status).toBe(200); + // The control discriminates: this object's effective layer is NOT its + // stored layer, so a door that served the effective layer would fail here. + expect(layered.doc.overlay).not.toBeNull(); + expect(layered.doc.effective).not.toEqual(layered.doc.overlay); + + expect(rest.status).toBe(200); + expect(labelOf(rest.doc)).toBe(OBJECT_LABEL); + expect(rest.doc).toEqual(layered.doc.overlay); + }); +}); diff --git a/packages/rest/src/meta-published-overlay.test.ts b/packages/rest/src/meta-published-overlay.test.ts index 59b6afc87ac..5e03deb47af 100644 --- a/packages/rest/src/meta-published-overlay.test.ts +++ b/packages/rest/src/meta-published-overlay.test.ts @@ -477,3 +477,128 @@ describe('[#8278] what the overlay consult must NOT change', () => { expect(res.body).toMatchObject({ label: 'Project Task' }); }, 60_000); }); + +/** + * [#21002, ADR-0126 §2] For a flow name the loader ships, the layered read's + * effective layer is the loader's body even when a stored row of that name is + * present (`isShippedFlowName` decides it). This door reads that layered answer, + * so when that decision was made it serves the effective layer; in every other + * case it serves the stored row exactly as before. The predicate is ASKED of the + * protocol, never re-derived here. + * + * The registry below ships ONE flow from a package — the package id stamped + * on the loader's own entry, the shape `lookupArtifactItem` reads off a + * partial registry — so the real protocol's predicate, code layer and layered + * read all run unmocked over the file's own engine double. The cold-boot proof over + * the real composition is `flow-shipped-name-published-door.dogfood.test.ts`. + */ +describe('[#21002] the published door follows the layered read for a shipped flow name', () => { + const SHIPPED = 'pkg_flow'; + const CUSTOMER = 'customer_flow'; + const flowBody = (name: string, label: string) => ({ + name, + label, + type: 'autolaunched', + nodes: [{ id: 'start', type: 'start', label: 'Start' }, { id: `${label.toLowerCase()}_end`, type: 'end', label: 'End' }], + edges: [{ id: 'e1', source: 'start', target: `${label.toLowerCase()}_end` }], + }); + + /** The file's engine double, with a registry that ships {@link SHIPPED} from a package. */ + function shippedHarness() { + const { engine, rows } = makeStubEngine(); + const loaderEntry = { ...flowBody(SHIPPED, 'LOADER'), _packageId: 'com.example.pkg' }; + engine.registry = { + registerItem: () => {}, + registerObject: () => {}, + getPackage: () => undefined, + getItem: (type: string, name: string) => + (type === 'flow' || type === 'flows') && name === SHIPPED ? loaderEntry : undefined, + }; + const metadata = new MetadataManager({}); + const protocol = makeProtocol(engine, metadata); + return { engine, rows, metadata, protocol }; + } + + async function storeActiveRow(engine: any, type: string, name: string, body: unknown) { + await engine.insert('sys_metadata', { + type, name, organization_id: null, package_id: null, state: 'active', + metadata: JSON.stringify(body), checksum: 'sha256:stored', version: 1, + }); + } + + it('a shipped flow name with a stored row: the door answers the loader\'s body, the layered effective layer', async () => { + const { engine, metadata, protocol } = shippedHarness(); + await storeActiveRow(engine, 'flow', SHIPPED, flowBody(SHIPPED, 'STORED')); + + // The decision this door follows: a stored layer IS present, and the + // layered read put the loader's body over it. + const layered: any = await protocol.getMetaItemLayered({ type: 'flow', name: SHIPPED }); + expect(layered.overlay).toMatchObject({ label: 'STORED' }); + expect(layered.effective).toMatchObject({ label: 'LOADER' }); + expect(protocol.isShippedFlowName('flow', SHIPPED)).toBe(true); + + const res = await callPublished(setup(protocol, metadata), { type: 'flow', name: SHIPPED }); + + expect(res.statusCode).toBe(200); + expect(res.body).toMatchObject({ name: SHIPPED, label: 'LOADER' }); + expect(JSON.stringify(res.body)).not.toContain('STORED'); + expect(res.body).toEqual(layered.effective); + }, 60_000); + + it('the plural type spelling reaches the same decision', async () => { + const { engine, metadata, protocol } = shippedHarness(); + await storeActiveRow(engine, 'flow', SHIPPED, flowBody(SHIPPED, 'STORED')); + + const res = await callPublished(setup(protocol, metadata), { type: 'flows', name: SHIPPED }); + + expect(res.statusCode).toBe(200); + expect(res.body).toMatchObject({ name: SHIPPED, label: 'LOADER' }); + }, 60_000); + + it('control: a flow name no package ships keeps its stored row on this door', async () => { + const { engine, metadata, protocol } = shippedHarness(); + await storeActiveRow(engine, 'flow', CUSTOMER, flowBody(CUSTOMER, 'STORED')); + + const layered: any = await protocol.getMetaItemLayered({ type: 'flow', name: CUSTOMER }); + const res = await callPublished(setup(protocol, metadata), { type: 'flow', name: CUSTOMER }); + + expect(protocol.isShippedFlowName('flow', CUSTOMER)).toBe(false); + expect(res.statusCode).toBe(200); + expect(res.body).toMatchObject({ name: CUSTOMER, label: 'STORED' }); + expect(res.body).toEqual(layered.overlay); + }, 60_000); + + it('control: an object\'s published stored row is served unchanged, not its effective layer', async () => { + const { metadata, protocol } = shippedHarness(); + await runtimePublish(protocol, 'proj_task', RUNTIME_BODY); + + const layered: any = await protocol.getMetaItemLayered({ type: 'object', name: 'proj_task' }); + // The control discriminates: the effective layer is NOT the stored + // row, so serving the effective layer here would fail the last line. + expect(layered.effective).not.toEqual(layered.overlay); + + const res = await callPublished(setup(protocol, metadata), { type: 'object', name: 'proj_task' }); + + expect(res.statusCode).toBe(200); + expect(res.body).toEqual(layered.overlay); + }, 60_000); + + it('control: a protocol that brings no such predicate keeps today\'s answer, the stored row', async () => { + const { engine, metadata, protocol } = shippedHarness(); + await storeActiveRow(engine, 'flow', SHIPPED, flowBody(SHIPPED, 'STORED')); + // The same protocol with the predicate hidden from the door only; its + // own methods keep calling it on the real instance. + const withoutPredicate = new Proxy(protocol, { + get(target, key) { + if (key === 'isShippedFlowName') return undefined; + const value = Reflect.get(target, key); + return typeof value === 'function' ? value.bind(target) : value; + }, + }); + + const res = await callPublished(setup(withoutPredicate, metadata), { type: 'flow', name: SHIPPED }); + + expect(res.statusCode).toBe(200); + expect(res.body).toMatchObject({ name: SHIPPED, label: 'STORED' }); + }, 60_000); +}); diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 8e09988a931..a97cfde7c22 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -8494,7 +8494,26 @@ export class RestServer { : {}), }); if (layered?.overlay !== undefined && layered?.overlay !== null) { - publishedOverlay = layered.overlay; + // [#21002, ADR-0126 §2] When the layered + // read put the LOADER's body over this stored + // row — a shipped flow name, decided by the + // protocol's `isShippedFlowName` — this door + // serves that effective layer, not the row: + // `flow` is Regime C, "never an overlay read + // path". The predicate is ASKED of its owner + // with the answer's own `type` / `name`, + // never re-derived here, so this door and + // `getMetaItemLayered` read one rule. Every + // other stored row is served exactly as + // before — an `object` too, whose effective + // layer differs from its row by folding, not + // by this decision — and so is every row of a + // protocol that brings no such predicate. + const shippedFlow: { isShippedFlowName?(type: string, name: unknown): boolean } = publishedProtocol; + publishedOverlay = typeof shippedFlow.isShippedFlowName === 'function' + && shippedFlow.isShippedFlowName(layered.type, layered.name) + ? layered.effective + : layered.overlay; } } catch (overlayError: any) { // [#5532] The overlay read is NOT blanket-swallowed, diff --git a/packages/runtime/src/domains/meta-published-runtime-publish.test.ts b/packages/runtime/src/domains/meta-published-runtime-publish.test.ts index 14ad781f07a..be231e603a6 100644 --- a/packages/runtime/src/domains/meta-published-runtime-publish.test.ts +++ b/packages/runtime/src/domains/meta-published-runtime-publish.test.ts @@ -326,3 +326,134 @@ describe('#8031 — GET /meta/:type/:name/published resolves from the published expect(response.status).toBe(404); }); }); + +/** + * [#21002, ADR-0126 §2] The dispatcher twin of `RestServer`'s `/published` + * follows the layered read the same way: for a flow name the loader ships, the + * layered read's effective layer is the loader's body even when a stored row of + * that name is present (`isShippedFlowName` decides it), and when that decision + * was made this door serves the effective layer. In every other case it serves + * the stored row exactly as before. The predicate is ASKED of the protocol, + * never re-derived here. + * + * The registry below ships ONE flow from a package — the package id stamped + * on the loader's own entry, the shape `lookupArtifactItem` reads off a + * partial registry — so the real protocol's predicate, code layer and layered + * read all run unmocked over this file's own engine double. The cold-boot proof over + * the real composition is `flow-shipped-name-published-door.dogfood.test.ts`. + */ +describe('[#21002] the dispatcher published door follows the layered read for a shipped flow name', () => { + const SHIPPED = 'pkg_flow'; + const CUSTOMER = 'customer_flow'; + const flowBody = (name: string, label: string) => ({ + name, + label, + type: 'autolaunched', + nodes: [{ id: 'start', type: 'start', label: 'Start' }, { id: `${label.toLowerCase()}_end`, type: 'end', label: 'End' }], + edges: [{ id: 'e1', source: 'start', target: `${label.toLowerCase()}_end` }], + }); + + /** This file's engine double, with a registry that ships {@link SHIPPED} from a package. */ + function shippedHarness() { + const { engine, rows } = makeStubEngine(); + const loaderEntry = { ...flowBody(SHIPPED, 'LOADER'), _packageId: 'com.example.pkg' }; + engine.registry = { + registerItem: () => {}, + registerObject: () => {}, + getPackage: () => undefined, + getItem: (type: string, name: string) => + (type === 'flow' || type === 'flows') && name === SHIPPED ? loaderEntry : undefined, + }; + const metadata = new MetadataManager({}); + const protocol = makeProtocol(engine, metadata); + return { engine, rows, metadata, protocol }; + } + + async function storeActiveRow(engine: any, type: string, name: string, body: unknown) { + await engine.insert('sys_metadata', { + type, name, organization_id: null, package_id: null, state: 'active', + metadata: JSON.stringify(body), checksum: 'sha256:stored', version: 1, + }); + } + + it('a shipped flow name with a stored row: the door answers the loader\'s body, the layered effective layer', async () => { + const { engine, metadata, protocol } = shippedHarness(); + await storeActiveRow(engine, 'flow', SHIPPED, flowBody(SHIPPED, 'STORED')); + + // The decision this door follows: a stored layer IS present, and the + // layered read put the loader's body over it. + const layered: any = await protocol.getMetaItemLayered({ type: 'flow', name: SHIPPED }); + expect(layered.overlay).toMatchObject({ label: 'STORED' }); + expect(layered.effective).toMatchObject({ label: 'LOADER' }); + expect(protocol.isShippedFlowName('flow', SHIPPED)).toBe(true); + + const response = responseOf(await make({ protocol, metadata }) + .handleMetadata(`/flow/${SHIPPED}/published`, ctx(), 'GET')); + + expect(response.status).toBe(200); + expect(response.body.data).toMatchObject({ name: SHIPPED, label: 'LOADER' }); + expect(JSON.stringify(response.body.data)).not.toContain('STORED'); + expect(response.body.data).toEqual(layered.effective); + }); + + it('the plural type spelling reaches the same decision', async () => { + const { engine, metadata, protocol } = shippedHarness(); + await storeActiveRow(engine, 'flow', SHIPPED, flowBody(SHIPPED, 'STORED')); + + const response = responseOf(await make({ protocol, metadata }) + .handleMetadata(`/flows/${SHIPPED}/published`, ctx(), 'GET')); + + expect(response.status).toBe(200); + expect(response.body.data).toMatchObject({ name: SHIPPED, label: 'LOADER' }); + }); + + it('control: a flow name no package ships keeps its stored row on this door', async () => { + const { engine, metadata, protocol } = shippedHarness(); + await storeActiveRow(engine, 'flow', CUSTOMER, flowBody(CUSTOMER, 'STORED')); + + const layered: any = await protocol.getMetaItemLayered({ type: 'flow', name: CUSTOMER }); + const response = responseOf(await make({ protocol, metadata }) + .handleMetadata(`/flow/${CUSTOMER}/published`, ctx(), 'GET')); + + expect(protocol.isShippedFlowName('flow', CUSTOMER)).toBe(false); + expect(response.status).toBe(200); + expect(response.body.data).toMatchObject({ name: CUSTOMER, label: 'STORED' }); + expect(response.body.data).toEqual(layered.overlay); + }); + + it('control: an object\'s published stored row is served unchanged, not its effective layer', async () => { + const { metadata, protocol } = shippedHarness(); + await runtimePublish(protocol, 'proj_task', RUNTIME_BODY); + + const layered: any = await protocol.getMetaItemLayered({ type: 'object', name: 'proj_task' }); + // The control discriminates: the effective layer is NOT the stored + // row, so serving the effective layer here would fail the last line. + expect(layered.effective).not.toEqual(layered.overlay); + + const response = responseOf(await make({ protocol, metadata }) + .handleMetadata('/object/proj_task/published', ctx(), 'GET')); + + expect(response.status).toBe(200); + expect(response.body.data).toEqual(layered.overlay); + }); + + it('control: a protocol that brings no such predicate keeps today\'s answer, the stored row', async () => { + const { engine, metadata, protocol } = shippedHarness(); + await storeActiveRow(engine, 'flow', SHIPPED, flowBody(SHIPPED, 'STORED')); + // The same protocol with the predicate hidden from the door only; its + // own methods keep calling it on the real instance. + const withoutPredicate = new Proxy(protocol, { + get(target, key) { + if (key === 'isShippedFlowName') return undefined; + const value = Reflect.get(target, key); + return typeof value === 'function' ? value.bind(target) : value; + }, + }); + + const response = responseOf(await make({ protocol: withoutPredicate, metadata }) + .handleMetadata(`/flow/${SHIPPED}/published`, ctx(), 'GET')); + + expect(response.status).toBe(200); + expect(response.body.data).toMatchObject({ name: SHIPPED, label: 'STORED' }); + }); +}); diff --git a/packages/runtime/src/domains/meta.ts b/packages/runtime/src/domains/meta.ts index 8201775c6dc..0c4ddceecbb 100644 --- a/packages/runtime/src/domains/meta.ts +++ b/packages/runtime/src/domains/meta.ts @@ -42,6 +42,9 @@ import { // which is the whole reason `MetaDomainProtocol` below is `Pick`ed rather than // written out. Same move `domains/packages.ts` and `domains/mcp.ts` make. import type { MetadataProtocol } from '@objectstack/spec/api'; +// [#21002] The implementation class, for the ONE member it declares that this +// domain asks (`isShippedFlowName`) — `Pick`ed below, never restated. +import type { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; // [#20193] THE per-caller read gate of a `/meta/:type/:name` document — the one // `RestServer` asks, published by `@objectstack/rest` so this transport asks it // too instead of a second audience resolver (ruling `5793362670` item 1). @@ -145,10 +148,18 @@ import type { DomainHandlerDeps, DomainRoute } from '../domain-handler-registry. * `unknown` rather than `string`, for the same reason the verbs above keep * `any` requests — nothing declares its type, and the only thing that branch * asks of it is whether it is `undefined`. + * + * [#21002] A third group: `isShippedFlowName`, the predicate the layered read + * decides its effective layer with, which `/published` asks so it follows that + * decision. Its signature is DECLARED — on `ObjectStackProtocolImplementation` + * itself — so it is `Pick`ed from that class, never restated: a rename at the + * producer is a compile error here, the same move `domains/automation.ts` + * makes for `packagedBaseRefusal`. */ export type MetaDomainProtocol = Partial> + & Partial> & { /** ⚠️ Undeclared request shapes — see "Where the ledger honestly ends". */ listDrafts?(request: any): Promise; @@ -1132,7 +1143,19 @@ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: strin ...(organizationId ? { organizationId } : {}), }); if (layered?.overlay !== undefined && layered?.overlay !== null) { - publishedOverlay = layered.overlay; + // [#21002, ADR-0126 §2] As `RestServer`'s `/published`: when + // the layered read put the LOADER's body over this stored + // row — a shipped flow name, decided by the protocol's + // `isShippedFlowName`, asked with the answer's own `type` / + // `name` and never re-derived here — serve that effective + // layer, not the row (`flow` is Regime C, "never an overlay + // read path"). Every other stored row, an `object`'s + // included, and every row of a protocol without the + // predicate, is served exactly as before. + publishedOverlay = typeof protocol.isShippedFlowName === 'function' + && protocol.isShippedFlowName(layered.type, layered.name) + ? layered.effective + : layered.overlay; } } catch { /* fall through to the code/package snapshot below */ } }