Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/20946-by-name-flow-read-shipped-name.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@objectstack/metadata-protocol': patch
---

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)

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 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.

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.

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.
Original file line number Diff line number Diff line change
@@ -0,0 +1,272 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#20946, #20761 ruling rule 1, ADR-0126 §2 / §3] The by-name read of a flow
* name the loader's set holds serves the loader's body — the body the list
* serves for that name — and never a stored row of that name under the
* package's provenance.
*
* `flow` is Regime C: the packaged base is locked, "⛔ Never silent override,
* never an overlay read path". Since #20913 the flattened view (both faces of
* `getMetaItems`) applies that by name, with two predicates: the stored-row
* half ({@link ObjectStackProtocolImplementation.isShippedFlowName}) and the
* registry half (`isStoredFlowEntryOfShippedName`). `getMetaItem` applied
* neither: it adopted the environment-wide stored row, then grafted the
* artifact's protection envelope over it, so the two read doors answered two
* different bodies for one name. It now calls the same two predicates.
*
* The registry double keeps the real `SchemaRegistry`'s key shape
* (`<package>:<name>` 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-by-name-read.dogfood.test.ts`.
*
* Controls: a flow name no managed package ships keeps its stored row (the
* tenant's own flow), 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 its overlay.
*/
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<string, unknown> = {}) => ({
name,
label,
type: 'autolaunched',
nodes: [
{ id: 'start', type: 'start', label: 'Start' },
{ id: 'end', type: 'end', label: 'End' },
],
edges: [{ id: 'e1', source: 'start', target: '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> = {}): 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<string, Map<string, Record<string, unknown>>>();
const collection = (type: string) => {
if (!byType.has(type)) byType.set(type, new Map());
return byType.get(type)!;
};
return {
registerItem(type: string, item: Record<string, unknown>, 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<string, unknown>) => {
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<string, unknown>)[k] === v),
);
};
const engine: any = {
async find(table: string, opts?: { where?: Record<string, unknown>; 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<string, unknown> }) {
// `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 };
}

const byName = async (protocol: ObjectStackProtocolImplementation, request: Record<string, unknown>) =>
((await protocol.getMetaItem(request as any)) as { item: Record<string, unknown> }).item;
const listed = async (protocol: ObjectStackProtocolImplementation, name: string) =>
((await protocol.getMetaItems({ type: 'flow' })) as { items: Array<Record<string, unknown>> }).items
.filter((it) => it.name === name);

describe('[#20946] the by-name read of a shipped flow name serves the loader\'s body', () => {
it('a shipped name with an environment-wide stored row answers the loader\'s body, not the row\'s', async () => {
const { protocol } = harness([storedRow('flow', SHIPPED, flowBody(SHIPPED, 'STORED'))]);

const served = await byName(protocol, { type: 'flow', name: SHIPPED });

expect(served.label).toBe('LOADER');
expect(served._packageId).toBe(PACKAGE_ID);
});

it('it does so after the row was hydrated — the registry\'s bare copy of the row does not stand in either', 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 served = await byName(protocol, { type: 'flow', name: SHIPPED });

expect(served.label).toBe('LOADER');
});

it('by name and in the list, the shipped name answers the same body', async () => {
const { protocol } = harness([storedRow('flow', SHIPPED, flowBody(SHIPPED, 'STORED'))]);

const list = await listed(protocol, SHIPPED);
const served = await byName(protocol, { type: 'flow', name: SHIPPED });

expect(list.map((it) => it.label)).toEqual(['LOADER']);
expect(served.label).toBe(list[0].label);
expect(served.nodes).toEqual(list[0].nodes);
});

it('the package-scoped read answers the loader\'s body too', async () => {
const { protocol } = harness([storedRow('flow', SHIPPED, flowBody(SHIPPED, 'STORED'))]);

const served = await byName(protocol, { type: 'flow', name: SHIPPED, packageId: PACKAGE_ID });

expect(served.label).toBe('LOADER');
});

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]);
expect((await byName(protocol, request)).label).toBe('LOADER');
}
}
});

it('the plural spelling of the type is folded first and answers the same', async () => {
const { protocol } = harness([storedRow('flow', SHIPPED, flowBody(SHIPPED, 'STORED'))]);

const served = await byName(protocol, { type: 'flows', name: SHIPPED });

expect(served.label).toBe('LOADER');
});

it('control: a flow name no managed package ships answers its stored row, unchanged', async () => {
const { protocol } = harness([storedRow('flow', CUSTOMER, flowBody(CUSTOMER, 'CUSTOMER'))]);

const served = await byName(protocol, { type: 'flow', name: CUSTOMER });

expect(served.label).toBe('CUSTOMER');
expect(isCodeArtifactBody(served)).toBe(false);
});

it('control: a shipped name with no stored row answers the loader\'s body, unchanged', async () => {
const { protocol } = harness([]);

const served = await byName(protocol, { type: 'flow', name: SHIPPED });

expect(served.label).toBe('LOADER');
});

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 }),
]);

expect((await byName(protocol, { type: 'flow', name: SHIPPED, organizationId: ORG_ID })).label).toBe('LOADER');
expect(await byName(protocol, { type: 'flow', name: CUSTOMER, organizationId: ORG_ID })).toBeUndefined();
});

it('control: an overlay of a packaged type in the overlay regime is still served over the artifact', async () => {
const { protocol } = harness([storedRow('view', 'pkg_view', { name: 'pkg_view', label: 'OVERLAY VIEW' })]);

const served = await byName(protocol, { type: 'view', name: 'pkg_view' });

expect(served.label).toBe('OVERLAY VIEW');
expect(served._packageId).toBe(PACKAGE_ID);
});
});
37 changes: 36 additions & 1 deletion packages/metadata-protocol/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8676,6 +8676,33 @@ export class ObjectStackProtocolImplementation implements
// Studio's editor opens a draft buffer with `state: 'draft'`;
// runtime loaders omit it and get the live published row.
const readState: 'active' | 'draft' = request.state === 'draft' ? 'draft' : 'active';
// ── [#20946, #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". Since #20913 the flattened view
// ({@link readFlattenedMetaItems}, both faces) serves a flow name the
// loader's set holds from the loader's own entries alone, with two
// predicates. This read applies the SAME two, and ⛔ no third precedence
// path of its own:
// • the stored-row half, {@link isShippedFlowName}, judged by NAME —
// step 1 below does not adopt the stored row of such a name, whatever
// its package binding or the stamps its own body carries;
// • the registry half, {@link isStoredFlowEntryOfShippedName} — step 3
// does not serve the registry's bare copy of that row (the hydrated
// tenant row `getItem` answers first); the loader's entry is served.
// Adopted, the row was served with the artifact's protection envelope
// grafted over it (the merge at the end of this method): a stored body
// answered as the package's definition by name, while the list answered
// the loader's body for the same name.
//
// Scoped to the ACTIVE read. A draft is answered as a draft (the strict
// `state: 'draft'` read and the `previewDrafts` arm), never under the
// artifact's envelope, and the list's own preview arm is equally
// unfiltered. Organization-scoped flow rows never reach this read:
// `orgId` is `undefined` for `flow`, which declares no org override.
// What becomes of the stored rows themselves (keep, refuse, migrate) is
// not decided here.
const shippedFlowActiveRead = readState === 'active' && this.isShippedFlowName(request.type, request.name);

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

// [#5840] The MetadataService half of the #5532 rule, and the last
Expand Down
Loading
Loading