diff --git a/.changeset/28412-flow-custom-override-armed.md b/.changeset/28412-flow-custom-override-armed.md
new file mode 100644
index 00000000000..71f1eb6bcab
--- /dev/null
+++ b/.changeset/28412-flow-custom-override-armed.md
@@ -0,0 +1,21 @@
+---
+'@objectstack/metadata-protocol': minor
+'@objectstack/service-automation': minor
+---
+
+feat(service-automation, metadata-protocol): a flow override declared in the shipping package's paired `.custom` is the packaged flow — saved, served and armed in place of the base, and deleting it restores the base (#28412)
+
+Clause-②: yes (widening)
+Metadata-protocol: semantic
+
+ADR-0146 D9 item 1 for `flow`. A flow a code package ships could be changed only by cloning it under a new name, so the clone and the base both ran on every record change. Now the base package's paired `.custom` package can override it in place:
+
+- **The save door admits it.** A flow saved into `.custom` under the base flow's name, declaring `overrides: { package: '', basedOn }`, passes the ADR-0146 D2 override write gate (`flow` is open there now; `action` still answers "not yet available"). The draft save, the per-item publish, `publishPackageDrafts`, `os validate` and the install-local gate admit it as they admit a declared view override.
+- **Every read serves it as the base.** The flow view (`getMetaItems` / `getMetaItemsForExecution`), the by-name read, the layered read (`code` = the base's flow, `effective` = the override) and the per-package customization list serve the override in the base's slot, under the base's `_packageId`, once per name. The one predicate is N-10's `isServedOverrideRowOf`, judged on the row's stored `package_id` — never on the body's `_packageId`.
+- **The automation engine arms it in place of the base.** The boot pull, the `kernel:ready` sync, the `metadata:reloaded` re-sync and the metadata-mutation sync each arm the override. One record change runs one body. The boot pull ranks it above the loader's body (a new `override` contender rank), reading the row's stored binding, which the protocol's registry hydration now records on the hydrated flow body as a non-enumerable symbol (`bindStoredRow` / `storedRowBindingOf`, newly exported). That symbol cannot be authored in JSON and never leaves the process.
+- **The ledger identity stays the base's.** The override keeps the base flow's name and owner, so the activation toggle, the ledger row, `FLOW_DISABLED` and the §7.3 subflow guard treat it as the base. Switched off, it is silent. Switched on, it is armed again.
+- **Deleting it restores the base.** A `/meta` delete naming the pair removes its row (admitted past the sealed-removal refusal, as the narrowing direction), and each arming step re-arms the base with one registration, never a withdraw first. A run suspended on the base finishes on the base (N-12), while new runs start on the override.
+- **Every other stored row stays shadowed.** A dependent package's declaration, a package-less row whose body names the pair, and the pair's undeclared row are not armed. The shadowing warning's remedy is now "declare it as an override in '.custom' (overrides: { package, basedOn }), or save it under a new name". It no longer says "clone".
+- **The sealed save sentence for a packaged flow** names the declaration in place of the clone route: "… Declare an override from '.custom' or rename it to customize it, or switch it off (…). See docs/adr/0126-packaged-metadata-customization-model.md." A delete still names the clone and the switch.
+
+No spec key is added or reshaped. A flow item's existing `overrides` declaration, refused before, now takes effect.
diff --git a/packages/cli/src/utils/override-declarations.test.ts b/packages/cli/src/utils/override-declarations.test.ts
index f40b9f49338..62afb34be97 100644
--- a/packages/cli/src/utils/override-declarations.test.ts
+++ b/packages/cli/src/utils/override-declarations.test.ts
@@ -98,12 +98,15 @@ describe('#28410 — findOverrideDeclarationRefusals walks the artifact under th
manifest: manifest(PAIR, [BASE]),
dashboards: [{ name: 'crm_board', overrides: { package: BASE, basedOn: '1.0.0' } }],
objects: [{ name: 'crm_account', overrides: { package: BASE, basedOn: '1.0.0' } }],
+ // [#28412, ADR-0146 D9] A declared flow override is admitted now (its D9
+ // conditions are delivered); an `action` declaration still waits.
flows: [{ name: 'crm_flow', overrides: { package: BASE, basedOn: '1.0.0' } }],
+ actions: [{ name: 'crm_call', objectName: 'crm_account', label: 'Call', overrides: { package: BASE, basedOn: '1.0.0' } }],
pages: [{ name: 'crm_home', overrides: { package: 'com.unlisted', basedOn: '1.0.0' } }],
});
expect(rows.map((r) => [r.path, r.rule])).toEqual([
['objects[0]', 'declared_object'],
- ['flows[0]', 'declared_type_not_yet_available'],
+ ['actions[0]', 'declared_type_not_yet_available'],
['pages[0]', 'declared_outside_pair'],
]);
});
diff --git a/packages/cloud-connection/src/marketplace-install-local-override-gate.test.ts b/packages/cloud-connection/src/marketplace-install-local-override-gate.test.ts
index 8be40f717c5..ac823791abf 100644
--- a/packages/cloud-connection/src/marketplace-install-local-override-gate.test.ts
+++ b/packages/cloud-connection/src/marketplace-install-local-override-gate.test.ts
@@ -110,6 +110,8 @@ const BASE_HOLDINGS: Holdings = {
[BASE]: {
dashboard: [{ name: 'crm_board', label: 'Board' }],
object: [{ name: 'crm_account' }],
+ flow: [{ name: 'crm_followup', label: 'Follow up' }],
+ action: [{ name: 'crm_call', label: 'Call' }],
},
};
@@ -179,6 +181,23 @@ describe('install-local 2d — the override write gate at the managed install (A
expect(res.payload.data).not.toHaveProperty('overrideCollisions');
});
+ it('[#28412] the paired .custom installs its declared FLOW override; a declared action override still waits', async () => {
+ // ADR-0146 D9: flow's conditions are delivered, so the gate admits it.
+ const flow = await install({
+ id: PAIR, version: '1.0.0', dependencies: deps(BASE),
+ flows: [{ name: 'crm_followup', label: 'Ours', overrides: { package: BASE, basedOn: '1.0.0' } }],
+ });
+ expect(flow.res.status, JSON.stringify(flow.res.payload)).toBe(200);
+ expect(flow.registered).toEqual([PAIR]);
+
+ const action = await install({
+ id: PAIR, version: '1.0.0', dependencies: deps(BASE),
+ actions: [{ name: 'crm_call', label: 'Ours', overrides: { package: BASE, basedOn: '1.0.0' } }],
+ });
+ expect(action.res.status).toBe(403);
+ expect(refusedOf(action.res)).toEqual([['action', 'crm_call', 'declared_type_not_yet_available']]);
+ });
+
it('CONTROL: a dependent shipping its own names installs, and its answer carries no collision key', async () => {
const { res, registered } = await install({
id: PARTNER, version: '1.0.0', dependencies: deps(BASE), dashboards: [{ name: 'partner_board' }],
diff --git a/packages/metadata-protocol/src/index.ts b/packages/metadata-protocol/src/index.ts
index 7b7680e54a5..8988e37a576 100644
--- a/packages/metadata-protocol/src/index.ts
+++ b/packages/metadata-protocol/src/index.ts
@@ -239,6 +239,12 @@ export { isServedOverrideRowOf } from './served-override.js';
export type { ServedOverrideRow } from './served-override.js';
export type { ServedOverrideEntry } from './protocol-served-overrides.js';
+// [#28412, ADR-0146 D9 item 1] The `package_id` column of the stored row a
+// registry body was hydrated from, recorded by the protocol's hydration and
+// read by the automation boot pull, which judges a flow override on the
+// column (`isServedOverrideRowOf`), ⛔ never on the body's `_packageId`.
+export { bindStoredRow, storedRowBindingOf } from './stored-row-binding.js';
+
// [ADR-0131 D6] The `unmanaged` install mode's walled-posture refusal — the
// function `importUnmanagedPackage` raises, exported so the install door
// (`@objectstack/cloud-connection` install-local) answers it with the same
diff --git a/packages/metadata-protocol/src/packaged-base-regime.ts b/packages/metadata-protocol/src/packaged-base-regime.ts
index fcc0c9c19c0..f887a85d1f2 100644
--- a/packages/metadata-protocol/src/packaged-base-regime.ts
+++ b/packages/metadata-protocol/src/packaged-base-regime.ts
@@ -338,6 +338,29 @@ export function packagedBaseRegimeSentence(
return lock + rowPrescription(row, name);
}
+/**
+ * [#28412, ADR-0146 D2 / D9 item 1] The sealed SAVE sentence of a Regime C
+ * item whose type the declared override opens (`flow`), or `undefined` for a
+ * type with no Regime C row. The lock of {@link packagedBaseRegimeSentence},
+ * then `declare` — the writer's own fix, the declaration from the base's
+ * paired `.custom` or a rename, phrased by the override write gate's module —
+ * in place of the row's clone path, then the row's switch, then the ADR-0126
+ * citation. The clone is not named: a clone is a second flow beside the base,
+ * and both run; the declared override is the one body that replaces it
+ * (ADR-0146 D9 item 1). The removal sentence keeps the row's prescription.
+ *
+ * Kept under the REST door's 500-character bound for an 88-character name
+ * (pinned beside the other rows).
+ */
+export function regimeCDeclaredSaveSentence(type: string, name: string, declare: string): string | undefined {
+ const singular = PLURAL_TO_SINGULAR[type] ?? type;
+ const row = packagedBaseRegimeRow(singular);
+ if (row?.regime !== 'C') return undefined;
+ const switchOff = row.routes.switchOff ? `, or switch it off (${row.routes.switchOff})` : '';
+ return `Metadata item '${singular}/${name}' is provided by a managed package and is sealed against in-place edits. `
+ + `${declare}${switchOff}. See ${REGIME_C_ADR}.`;
+}
+
/** [ADR-0131 D6] The decision record every sealed-item sentence without a regime row cites. */
const MANAGED_SEAL_ADR = 'docs/adr/0131-total-organization-ownership-no-null-organization-id.md';
diff --git a/packages/metadata-protocol/src/protocol-lock-state.ts b/packages/metadata-protocol/src/protocol-lock-state.ts
index 02849f81e3b..57797410d47 100644
--- a/packages/metadata-protocol/src/protocol-lock-state.ts
+++ b/packages/metadata-protocol/src/protocol-lock-state.ts
@@ -15,6 +15,11 @@
* re-indented one level and otherwise unedited: the same reads, in the same
* order, throwing exactly where they did with the same message.
*
+ * Edited since: [#28412, ADR-0146 D9 item 1] `declinesStoredRow` takes the
+ * stored row it judges, and does not decline a shipped flow name's row that
+ * the one served-override predicate (`isServedOverrideRowOf`) admits for the
+ * shipping package — its declared `.custom` override, the packaged flow.
+ *
* ## How the moved bodies reach the class
*
* The shape #23050 set for `rest-server.ts` (its `data-action-routes.ts`
@@ -44,6 +49,7 @@ import type { ObjectStackProtocolImplementation } from './protocol.js';
import { canonicalMetaType } from './protocol.js';
import type { ItemLock } from './item-lock.js';
import { isOriginGatedType } from './packaged-base-regime.js';
+import { isServedOverrideRowOf, type ServedOverrideRow } from './served-override.js';
import { isCodeArtifactBody } from '@objectstack/metadata-core';
import { PLURAL_TO_SINGULAR } from '@objectstack/spec/shared';
import {
@@ -131,9 +137,15 @@ export function isStoredFlowEntryOfShippedName(
/** The body of `ObjectStackProtocolImplementation#declinesStoredRow`; the method's docblock there is its contract. */
export function declinesStoredRow(
- this: Pick,
- type: string, name: unknown): boolean {
- if (this.isShippedFlowName(type, name)) return true;
+ this: Pick,
+ type: string, name: unknown, row?: ServedOverrideRow): boolean {
+ if (this.isShippedFlowName(type, name)) {
+ // [#28412, ADR-0146 D9 item 1] …except the one row that IS the
+ // packaged flow now: the shipping package's declared override, judged
+ // on the row's own `package_id` and body by the one served-override
+ // predicate. Every other stored row of the name stays declined.
+ return !isServedOverrideRowOf(row, this.packagedArtifactOwner({ type: 'flow', name: name as string }));
+ }
if (typeof name !== 'string' || name === '') return false;
return this.isDeclaredCodeDatasource(type, name) || this.isUnservedSealedOverlay(type, name);
}
diff --git a/packages/metadata-protocol/src/protocol-packaged-base-refusal.ts b/packages/metadata-protocol/src/protocol-packaged-base-refusal.ts
index 27271b43e2f..2943bd077b6 100644
--- a/packages/metadata-protocol/src/protocol-packaged-base-refusal.ts
+++ b/packages/metadata-protocol/src/protocol-packaged-base-refusal.ts
@@ -36,7 +36,7 @@
*/
import type { ObjectStackProtocolImplementation } from './protocol.js';
-import { isOriginGatedType, managedItemSealedSentence } from './packaged-base-regime.js';
+import { isOriginGatedType, managedItemSealedSentence, regimeCDeclaredSaveSentence } from './packaged-base-regime.js';
import { SysMetadataRepository } from './sys-metadata-repository.js';
import {
customizationBasePackageId,
@@ -144,9 +144,14 @@ export function refusePackagedBaseOverride(
// of the bare seal: the override write gate ({@link judgeOverrideWrite})
// admits exactly the pair's declaration, so the seal is no longer the
// whole story.
+ // [#28412] A Regime C type the declared override opens (`flow`) names
+ // the declaration in place of its clone path, keeping its switch.
+ const owner = this.packagedArtifactOwner(request);
+ const declare = sealedDeclareOrRenameClause(request.type, owner, request.packageId);
const err = new Error(
- managedItemSealedSentence(request.type, request.name, 'save')
- + sealedDeclareOrRenameTail(request.type, this.packagedArtifactOwner(request), request.packageId),
+ (declare === undefined ? undefined : regimeCDeclaredSaveSentence(request.type, request.name, declare))
+ ?? managedItemSealedSentence(request.type, request.name, 'save')
+ + sealedDeclareOrRenameTail(request.type, owner, request.packageId),
);
(err as any).code = 'NOT_OVERRIDABLE';
(err as any).status = 403;
@@ -164,9 +169,10 @@ export function refusePackagedBaseOverride(
* (ruling A on #28390), so a dependent's one fix is a new name, and the
* declare advice would send it to a refusal (`declared_outside_pair`).
*
- * Empty for a type with no open channel — `flow` and `action` wait for
- * ADR-0146 D9's conditions (their regime rows keep their own routes), and an
- * excluded type has none — and when the shipping package has no pair id.
+ * Empty for a type with no open channel — `action` waits for ADR-0146 D9's
+ * conditions (its regime row keeps its own routes), and an excluded type has
+ * none — and when the shipping package has no pair id. [#28412] `flow` is
+ * open: its sealed sentence names the declaration too.
*/
function sealedDeclareOrRenameTail(type: string, basePackageId: unknown, writerPackageId: unknown): string {
const singular = PLURAL_TO_SINGULAR[type] ?? type;
@@ -181,21 +187,83 @@ function sealedDeclareOrRenameTail(type: string, basePackageId: unknown, writerP
+ `(overrides: { package: '${basePackageId}', basedOn }), or rename the item.`;
}
+/**
+ * [#28412, ADR-0146 D2] {@link sealedDeclareOrRenameTail}'s choice, as the
+ * clause a Regime C sentence carries in place of its clone path
+ * (`regimeCDeclaredSaveSentence`): the pair (or a write naming no package)
+ * may declare from the pair or rename; any other named package may only
+ * rename. `undefined` exactly where the tail is empty.
+ */
+function sealedDeclareOrRenameClause(type: string, basePackageId: unknown, writerPackageId: unknown): string | undefined {
+ const singular = PLURAL_TO_SINGULAR[type] ?? type;
+ if (!isOpenOverrideType(singular) || typeof basePackageId !== 'string') return undefined;
+ const pair = customizationPackageId(basePackageId);
+ if (!pair.ok) return undefined;
+ const writer = typeof writerPackageId === 'string' && writerPackageId.length > 0 ? writerPackageId : undefined;
+ if (writer !== undefined && writer !== pair.value) {
+ return `Rename it to customize it (only '${pair.value}' may declare an override of '${basePackageId}')`;
+ }
+ return `Declare an override from '${pair.value}' or rename it to customize it`;
+}
+
// ─────────────────────────────────────────────────────────────────────────
// ADR-0146 D2 — the override write gate (ruling A on decision card #28390)
// ─────────────────────────────────────────────────────────────────────────
+/**
+ * [#28412, ADR-0146 D9] The behavioural types whose D9 conditions this runtime
+ * delivers, so the gate admits their declared override:
+ *
+ * - `flow` — all four. Served over the code package and rebound in one reload
+ * (`@objectstack/service-automation`'s flow precedence and the flow view of
+ * this package, which admit exactly the `.custom` row
+ * `isServedOverrideRowOf` names); ledger identity inherited (the
+ * override keeps the base's name, so `packagedFlowOwner`, the toggle door,
+ * the §7.3 subflow guard and the `FLOW_DISABLED` consult key it as the
+ * base); version pinning (#28405: a suspended run finishes on the body it
+ * started on).
+ *
+ * ⛔ Not `action`: its override door is not cut (D9's `action` paragraph), so
+ * its declaration keeps the "not yet available" refusal. A type joins this set
+ * in the change that delivers its conditions, never ahead of it.
+ */
+const CONDITIONS_DELIVERED: ReadonlySet = new Set(['flow']);
+
/**
* Whether a declared override of `type` is admitted today: the closed list's
* disposition is `overridable` AND the type waits for no ADR-0146 D9
- * condition. `flow` (D9 conditions, opened by its own card in this same gate)
- * and `action` (its ledger switch first; refused for the whole epic) carry
- * `conditions`, so they are classified overridable and still refused here.
- * Read from `OVERRIDE_TYPE_CLASSIFICATION`, never re-derived.
+ * condition this runtime has not delivered ({@link CONDITIONS_DELIVERED}).
+ * `flow` carries `conditions` and is delivered; `action` (its door is not cut)
+ * carries them and is not, so it is classified overridable and still refused
+ * here. The disposition and the conditions are read from
+ * `OVERRIDE_TYPE_CLASSIFICATION`, never re-derived.
*/
function isOpenOverrideType(singular: string): boolean {
const row = getOverrideTypeClassification(singular);
- return row?.disposition === 'overridable' && (row.conditions?.length ?? 0) === 0;
+ if (row?.disposition !== 'overridable') return false;
+ return (row.conditions?.length ?? 0) === 0 || CONDITIONS_DELIVERED.has(singular);
+}
+
+/**
+ * [#28412, ADR-0146 D9 item 1] Whether a removal addressed to `packageId`
+ * removes the shipping package's PAIR row of a type whose D9 conditions are
+ * delivered ({@link CONDITIONS_DELIVERED}: `flow` today) — the row the pair's
+ * declared override is stored in. Such a removal is admitted past the
+ * sealed-removal refusal: it removes no managed definition, and the code
+ * package's flow is what every read serves and what the automation engine
+ * arms once the row is gone — the narrowing direction the #6960 ruling names.
+ * ⛔ Only the pair, named: a removal naming no package, or any other package,
+ * keeps the sealed refusal it had. ⛔ Only a delivered type: every other open
+ * type (`view`, `mapping`, …) keeps main's delete verdict — its own removal
+ * carve-out or its sealed refusal — untouched by this card.
+ */
+export function isPairRowRemoval(type: string, basePackageId: unknown, packageId: unknown): boolean {
+ const singular = PLURAL_TO_SINGULAR[type] ?? type;
+ if (!CONDITIONS_DELIVERED.has(singular) || !isOpenOverrideType(singular)) return false;
+ if (typeof basePackageId !== 'string') return false;
+ if (typeof packageId !== 'string' || packageId.length === 0) return false;
+ const pair = customizationPackageId(basePackageId);
+ return pair.ok && pair.value === packageId;
}
/** One item written into one package — what the override write gate judges. */
@@ -312,9 +380,10 @@ export function judgeOverrideWrite(
`${item} carries \`overrides\`, but a ${type} takes no override (ADR-0146 D6). `
+ `Remove \`overrides\` and create your own ${type} under a new name ("new similar").`);
}
- // `flow` and `action` are classified overridable and wait for D9's
- // conditions (`isOpenOverrideType`): "flow customization is not yet
- // available", and the same sentence for an action.
+ // `action` is classified overridable and waits for D9's conditions
+ // (`isOpenOverrideType`): "action customization is not yet available".
+ // [#28412] `flow`'s conditions are delivered, so it is judged below
+ // like every open type.
if (!isOpenOverrideType(type)) {
return refuse('declared_type_not_yet_available',
`${item} carries \`overrides\`: ${type} customization is not yet available, so a declared ${type} `
diff --git a/packages/metadata-protocol/src/protocol-served-overrides.ts b/packages/metadata-protocol/src/protocol-served-overrides.ts
index 77728a958b9..f2df8302718 100644
--- a/packages/metadata-protocol/src/protocol-served-overrides.ts
+++ b/packages/metadata-protocol/src/protocol-served-overrides.ts
@@ -31,10 +31,12 @@
* leaves a legacy organization's out, ADR-0131 D6/D7). No per-organization
* axis exists to filter on (ADR-0131 D6), so every caller gets one answer.
* - A row the reads decline ({@link ObjectStackProtocolImplementation.declinesStoredRow}:
- * a stored row under a shipped flow name, a code-defined datasource name,
- * sealed managed content) is not listed. Its declaration may be well
- * formed, but the base's document is what every read serves for the name
- * (ADR-0146 D9's conditions are N-11's), so it overrides nothing yet.
+ * a code-defined datasource name, sealed managed content) is not listed.
+ * Its declaration may be well formed, but the base's document is what
+ * every read serves for the name, so it overrides nothing. [#28412] A
+ * shipped flow name's declared override is not declined (ADR-0146 D9
+ * item 1): the reads serve it over the base's flow, so it is listed; the
+ * row is handed over whole, so the decline judges it on its own binding.
*
* ## What it reports, and what it never computes
*
@@ -112,7 +114,7 @@ export async function listServedOverrides(
if (typeof row?.name !== 'string' || row.name === '') continue;
if (!isServedOverrideRowOf(row, base)) continue;
const type = PLURAL_TO_SINGULAR[row.type] ?? row.type;
- if (this.declinesStoredRow(type, row.name)) continue;
+ if (this.declinesStoredRow(type, row.name, row)) continue;
const member = typeof row.bundle_member === 'string' && row.bundle_member !== '' ? row.bundle_member : undefined;
// `isServedOverrideRowOf` parsed this body already; a body it admitted parses.
const declaration = (storedRowDocument(row) as { overrides?: { basedOn?: unknown } }).overrides;
diff --git a/packages/metadata-protocol/src/protocol.flow-stored-row-shipped-name.test.ts b/packages/metadata-protocol/src/protocol.flow-stored-row-shipped-name.test.ts
index 92227b22344..e71fa193c18 100644
--- a/packages/metadata-protocol/src/protocol.flow-stored-row-shipped-name.test.ts
+++ b/packages/metadata-protocol/src/protocol.flow-stored-row-shipped-name.test.ts
@@ -30,6 +30,7 @@
import { describe, expect, it } from 'vitest';
import { isCodeArtifactBody } from '@objectstack/metadata-core';
import { ObjectStackProtocolImplementation } from './protocol.js';
+import { storedRowBindingOf } from './stored-row-binding.js';
const PACKAGE_ID = 'com.example.pkg';
const SHIPPED = 'pkg_flow';
@@ -226,3 +227,91 @@ describe('[#20913] the flattened flow view serves a shipped name from the loader
expect(served.map((it) => it.label)).toEqual(['OVERLAY VIEW']);
});
});
+
+/**
+ * [#28412, ADR-0146 D9 item 1] …except ONE stored row: the shipping package's
+ * declared override. A row bound — by its own `package_id` column — to the
+ * package's paired `.custom`, whose body declares
+ * `overrides.package` = the package, is the packaged flow now: the flow view
+ * (both faces), the by-name read, the layered read and the per-package
+ * customization read serve it over the loader's body, under the package's
+ * provenance, once per name. The predicate is the one served-override
+ * predicate (`isServedOverrideRowOf`); every other stored row of the name —
+ * a dependent's declaration, a package-less row whose body names the pair, an
+ * undeclared pair row — stays declined, as above.
+ */
+describe('[#28412] the flow view admits exactly the shipping package\'s declared .custom override', () => {
+ const PAIR = `${PACKAGE_ID}.custom`;
+ const DECLARED = { overrides: { package: PACKAGE_ID, basedOn: '1.0.0' } };
+ const overrideRow = (extra: Partial = {}, body: Record = DECLARED) =>
+ storedRow('flow', SHIPPED, flowBody(SHIPPED, 'OVERRIDE', body), { package_id: PAIR, ...extra });
+
+ it('the execution view serves the declared override once, in the package\'s slot, under its provenance', async () => {
+ const { protocol } = harness([overrideRow()]);
+ const served = named(await protocol.getMetaItemsForExecution({ type: 'flow' }), SHIPPED);
+ expect(served.map((it) => it.label)).toEqual(['OVERRIDE']);
+ expect(served[0]._packageId).toBe(PACKAGE_ID);
+ expect(served[0].overrides).toEqual(DECLARED.overrides);
+ });
+
+ it('…on every read: the registry\'s hydrated copy does not add a second entry', async () => {
+ const { protocol } = harness([overrideRow()]);
+ await protocol.getMetaItemsForExecution({ type: 'flow' });
+ const second = named(await protocol.getMetaItemsForExecution({ type: 'flow' }), SHIPPED);
+ expect(second.map((it) => it.label)).toEqual(['OVERRIDE']);
+ });
+
+ it('the served list agrees with the execution view', async () => {
+ // The by-name and layered reads' agreement is pinned over the real
+ // engine (`protocol-flow-custom-override.test.ts`, packages/objectql):
+ // this double answers no `findOne` and no by-name registry read.
+ const { protocol } = harness([overrideRow()]);
+ expect(named(await protocol.getMetaItems({ type: 'flow' }), SHIPPED).map((it) => it.label)).toEqual(['OVERRIDE']);
+ });
+
+ it('the per-package customization read lists the flow override of the package', async () => {
+ const { protocol } = harness([overrideRow()]);
+ expect(await (protocol as any).listServedOverrides({ packageId: PACKAGE_ID }))
+ .toEqual([{ type: 'flow', name: SHIPPED, basedOn: '1.0.0' }]);
+ });
+
+ it('the hydration records the row\'s own package_id on the registry body, never the body\'s stamp', async () => {
+ const { protocol, registry } = harness([overrideRow({}, { ...DECLARED, _packageId: PACKAGE_ID })]);
+ await protocol.getMetaItemsForExecution({ type: 'flow' });
+ const hydrated = registry.bare('flow', SHIPPED)!;
+ expect(storedRowBindingOf(hydrated)).toBe(PAIR);
+ // [#28594] The served stamp is the column too (`storedOverlayEntries`
+ // replaces the body's own `PACKAGE_ID` stamp); the binding above is
+ // still read from the symbol, never from this enumerable key.
+ expect(hydrated._packageId).toBe(PAIR);
+ expect(JSON.parse(JSON.stringify(hydrated))).not.toHaveProperty('storedRowBinding');
+
+ const packageless = harness([storedRow('flow', SHIPPED, flowBody(SHIPPED, 'STAMPED', { ...DECLARED, _packageId: PAIR }))]);
+ await packageless.protocol.getMetaItemsForExecution({ type: 'flow' });
+ expect(storedRowBindingOf(packageless.registry.bare('flow', SHIPPED))).toBeNull();
+ });
+
+ for (const [label, row] of [
+ ['a dependent package\'s declaration', overrideRow({ package_id: 'com.example.partner' })],
+ ['a package-less row whose body stamps the pair', storedRow('flow', SHIPPED,
+ flowBody(SHIPPED, 'OVERRIDE', { ...DECLARED, _packageId: PAIR }))],
+ ['an undeclared row of the pair', overrideRow({}, {})],
+ ['another base\'s pair declaring this package', overrideRow({ package_id: 'com.other.custom' })],
+ ] as const) {
+ it(`${label} is not admitted: the loader's body is served, and nothing is listed as an override`, async () => {
+ const { protocol } = harness([row]);
+ expect(named(await protocol.getMetaItemsForExecution({ type: 'flow' }), SHIPPED).map((it) => it.label))
+ .toEqual(['LOADER']);
+ expect(named(await protocol.getMetaItems({ type: 'flow' }), SHIPPED).map((it) => it.label)).toEqual(['LOADER']);
+ expect(await (protocol as any).listServedOverrides({ packageId: PACKAGE_ID })).toEqual([]);
+ });
+ }
+
+ it('deleting the override serves the loader\'s body again on the next read', async () => {
+ const rows = [overrideRow()];
+ const { protocol } = harness(rows);
+ expect(named(await protocol.getMetaItemsForExecution({ type: 'flow' }), SHIPPED).map((it) => it.label)).toEqual(['OVERRIDE']);
+ rows.splice(0, 1);
+ expect(named(await protocol.getMetaItemsForExecution({ type: 'flow' }), SHIPPED).map((it) => it.label)).toEqual(['LOADER']);
+ });
+});
diff --git a/packages/metadata-protocol/src/protocol.packaged-base-refusal.test.ts b/packages/metadata-protocol/src/protocol.packaged-base-refusal.test.ts
index afc83180017..e8795858102 100644
--- a/packages/metadata-protocol/src/protocol.packaged-base-refusal.test.ts
+++ b/packages/metadata-protocol/src/protocol.packaged-base-refusal.test.ts
@@ -237,14 +237,23 @@ describe('packagedBaseRefusal — the sentence is chosen per ADR-0126 regime', (
for (const environmentId of [undefined, 'env_1']) {
for (const operation of ['save', 'delete'] as const) {
- it(`a packaged flow's ${operation} refusal names clone and the switch, cites ADR-0126, and names no hatch `
+ // [#28412, ADR-0146 D9 item 1] The SAVE names the declaration from
+ // the base's paired `.custom` in place of the clone: a clone is a
+ // second flow beside the base, and both run. The removal keeps
+ // the row's prescription.
+ it(`a packaged flow's ${operation} refusal names ${operation === 'save' ? 'the declaration' : 'clone'} and the switch, cites ADR-0126, and names no hatch `
+ `(${environmentId ? 'environment' : 'host-config'} kernel)`, () => {
const refusal: any = protocolOn(environmentId)
.packagedBaseRefusal({ type: 'flow', name: 'pkg_flow', operation });
expect(shape(refusal)).toEqual({ code: 'NOT_OVERRIDABLE', status: 403 });
const message = String(refusal.message);
expect(message.startsWith("Metadata item 'flow/pkg_flow' is provided by a managed package")).toBe(true);
- expect(message).toContain(CLONE);
+ if (operation === 'save') {
+ expect(message).toContain(`Declare an override from '${PACKAGE_ID}.custom'`);
+ expect(message).not.toContain(CLONE);
+ } else {
+ expect(message).toContain(CLONE);
+ }
expect(message).toContain(TOGGLE);
expect(message).toContain(ADR_0126);
expect(message).not.toContain('OS_METADATA_WRITABLE');
@@ -269,7 +278,9 @@ describe('packagedBaseRefusal — the sentence is chosen per ADR-0126 regime', (
const thrownBy = (run: Promise) => run.then(() => undefined, (e: any) => e);
const save: any = await thrownBy(p.saveMetaItem({ type: 'flow', name: 'pkg_flow', item: { name: 'pkg_flow', label: 'x' } }));
const del: any = await thrownBy(p.deleteMetaItem({ type: 'flow', name: 'pkg_flow' }));
- expect(save?.message).toContain(CLONE);
+ // [#28412] The save names the declaration in place of the clone.
+ expect(save?.message).toContain(`Declare an override from '${PACKAGE_ID}.custom'`);
+ expect(save?.message).toBe(p.packagedBaseRefusal({ type: 'flow', name: 'pkg_flow', operation: 'save' })?.message);
expect(del?.message).toContain(TOGGLE);
});
@@ -340,10 +351,12 @@ describe('packagedBaseRefusal — each Regime C type names its OWN sanctioned pa
// Spelled out literally, NOT through the builder: the builder cannot
// move a byte of the flow sentence without this pin seeing it.
// [ADR-0131 D6] The opener names the managed package and the seal.
+ // [#28412, ADR-0146 D9 item 1] The save names the declaration from the
+ // base's paired `.custom` (or a rename) in place of the clone.
expect(refusal('flow', 'pkg_flow', 'save').message).toBe(
"Metadata item 'flow/pkg_flow' is provided by a managed package and is sealed "
- + 'against in-place edits. Clone it under a new name to customize it (POST /api/v1/automation/:name/clone, '
- + 'body {name, label}), or switch it off (POST /api/v1/automation/:name/toggle, body {enabled: false}; '
+ + "against in-place edits. Declare an override from 'com.example.pkg.custom' or rename it to customize it, "
+ + 'or switch it off (POST /api/v1/automation/:name/toggle, body {enabled: false}; '
+ 'operator-only where one install serves several organizations). '
+ 'See docs/adr/0126-packaged-metadata-customization-model.md.',
);
@@ -741,11 +754,49 @@ describe('the override write gate — only the base\'s paired .custom declares a
expect(stored()).toEqual([]);
});
- it('a declared flow override is refused — flow customization is not yet available', async () => {
+ // [#28412, ADR-0146 D9] `flow`'s D9 conditions are delivered (the flow
+ // precedence arms the pair's declaration over the loader's body, version
+ // pinning landed in #28405), so the same gate admits the pair's declared
+ // flow override — refused until this card as "not yet available" — and
+ // keeps every other refusal. `action` stays closed (the next case).
+ it('the pair\'s declared flow override is admitted at the save door and stored with its declaration (#28412)', async () => {
+ const { protocol, rows, stored } = gateProtocol();
+ await protocol.saveMetaItem({ type: 'flow', name: 'crm_flow', packageId: PAIR, item: body('flow', 'crm_flow', true) });
+ expect(stored()).toEqual([{ type: 'flow', name: 'crm_flow', package_id: PAIR, state: 'active' }]);
+ expect(JSON.parse(Array.from(rows.values())[0].metadata).overrides).toEqual(DECLARATION);
+ });
+
+ it('publishMetaItem promotes the pair\'s declared flow override draft (#28412)', async () => {
+ const { protocol, stored, seedDraft } = gateProtocol();
+ seedDraft('flow', 'crm_flow', PAIR, body('flow', 'crm_flow', true));
+ await protocol.publishMetaItem({ type: 'flow', name: 'crm_flow', packageId: PAIR });
+ expect(stored()).toEqual([{ type: 'flow', name: 'crm_flow', package_id: PAIR, state: 'active' }]);
+ });
+
+ it('publishPackageDrafts promotes the pair\'s declared flow override (#28412)', async () => {
+ const { protocol, stored, seedDraft } = gateProtocol();
+ seedDraft('flow', 'crm_flow', PAIR, body('flow', 'crm_flow', true));
+ const res = await protocol.publishPackageDrafts({ packageId: PAIR });
+ expect(res.success).toBe(true);
+ expect(stored()).toEqual([{ type: 'flow', name: 'crm_flow', package_id: PAIR, state: 'active' }]);
+ });
+
+ it('a partner\'s declared flow override is still refused — only the pair declares (#28412)', async () => {
+ const { protocol, stored } = gateProtocol();
+ const e = await thrown(protocol.saveMetaItem({ type: 'flow', name: 'crm_flow', packageId: PARTNER, item: body('flow', 'crm_flow', true) }));
+ expect(shape(e)).toEqual(REFUSED);
+ expect(String(e.message)).toContain(`only '${PAIR}' may declare`);
+ expect(String(e.message)).not.toMatch(/not yet available/);
+ expect(stored()).toEqual([]);
+ });
+
+ it('the pair\'s undeclared same-named flow is refused — declare or rename, never clone (#28412)', async () => {
const { protocol, stored } = gateProtocol();
- const e = await thrown(protocol.saveMetaItem({ type: 'flow', name: 'crm_flow', packageId: PAIR, item: body('flow', 'crm_flow', true) }));
+ const e = await thrown(protocol.saveMetaItem({ type: 'flow', name: 'crm_flow', packageId: PAIR, item: body('flow', 'crm_flow') }));
expect(shape(e)).toEqual(REFUSED);
- expect(String(e.message)).toMatch(/flow customization is not yet available/);
+ expect(String(e.message)).toMatch(/[Dd]eclare/);
+ expect(String(e.message)).not.toMatch(/not yet available/);
+ expect(String(e.message).toLowerCase()).not.toContain('clone');
expect(stored()).toEqual([]);
});
diff --git a/packages/metadata-protocol/src/protocol.register-item-call-sites.test.ts b/packages/metadata-protocol/src/protocol.register-item-call-sites.test.ts
index a1a4c6543ce..991430ef585 100644
--- a/packages/metadata-protocol/src/protocol.register-item-call-sites.test.ts
+++ b/packages/metadata-protocol/src/protocol.register-item-call-sites.test.ts
@@ -96,7 +96,9 @@ const SITES: ReadonlyArray {
: '';
};
+/**
+ * [#28412] The Regime C types whose declared override is open although the
+ * closed list records D9 conditions for them: the conditions are delivered
+ * (`CONDITIONS_DELIVERED` in `protocol-packaged-base-refusal.ts`).
+ */
+const OPEN_REGIME_C: ReadonlySet = new Set(['flow']);
+
/** The ruled sentence, for a type with no regime row. */
const oneSentence = (type: string, name: string) =>
`'${type}/${name}' is provided by a managed package and is sealed: it cannot be written here. `
@@ -122,7 +129,17 @@ describe('[#23577] every write over a sealed, package-held name reads one senten
rowless += 1;
} else if (row.regime === 'C') {
// ADR-0126 §2: the sanctioned path, read from the row.
- for (const route of Object.values(row.routes)) if (route) expect(message, type).toContain(route);
+ // [#28412, ADR-0146 D9] A Regime C type whose declared
+ // override is open names the declaration in place of its
+ // clone route: the clone is a second item beside the base,
+ // and both would run.
+ const declares = declareOrRename(type) !== '' || OPEN_REGIME_C.has(type);
+ for (const [kind, route] of Object.entries(row.routes)) {
+ if (!route) continue;
+ if (declares && kind === 'clone') expect(message, type).not.toContain(route);
+ else expect(message, type).toContain(route);
+ }
+ if (declares) expect(message, type).toContain(`Declare an override from '${PACKAGE_ID}.custom'`);
expect(message, type).not.toMatch(SOURCE_ADVICE);
} else {
// The origin-gated row: a code-defined datasource, whose one
diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts
index 8bc2d3e7235..f11bdd7ca97 100644
--- a/packages/metadata-protocol/src/protocol.ts
+++ b/packages/metadata-protocol/src/protocol.ts
@@ -183,7 +183,8 @@ import { deriveNamespaceFromPackageId } from '@objectstack/spec/kernel';
// and the one predicate that says a stored row is the served override of a
// base's name.
import { customizationPackageId } from '@objectstack/spec/kernel';
-import { isServedOverrideRowOf } from './served-override.js';
+import { isServedOverrideRowOf, type ServedOverrideRow } from './served-override.js';
+import { bindStoredRow } from './stored-row-binding.js';
import { listServedOverrides, type ServedOverrideEntry } from './protocol-served-overrides.js';
// [#19417] The package-id declaration and its remedy text, imported to be
// SURFACED rather than restated — see the gate at the top of `installPackage`.
@@ -387,6 +388,7 @@ import { applyPublishBackfill, planPublishBackfill } from './protocol-publish-ba
import { discardDraftInItsScope, listDrafts } from './protocol-drafts.js';
// [#25467 cut 13] The package door's save refusal and the origin-gated removal verdict.
import {
+ isPairRowRemoval,
originGatedRemovalRefusal,
overrideWriteRefusalError,
overrideWriteVerdict,
@@ -6487,6 +6489,15 @@ export interface StoredOverlayEntry {
type: string;
}
+/**
+ * [#28412] A parsed stored row as {@link ObjectStackProtocolImplementation.declinesStoredRow}
+ * judges it: its stored type spelling, its `package_id` column (the entry's
+ * `packageId`, never the body's `_packageId`) and its parsed body.
+ */
+function storedEntryRow(entry: Pick): ServedOverrideRow {
+ return { type: entry.type, package_id: entry.packageId ?? null, metadata: entry.data };
+}
+
/**
* [#21442] A row-less view name a stored view container expands: the item the
* list read serves under the name, and the stored container row it derives
@@ -9911,8 +9922,13 @@ export class ObjectStackProtocolImplementation implements
// [#21922] The same for a row under a code-defined datasource
// name ({@link declinesStoredRow}): the MetadataService's code
// definition, merged in below, is the item the list serves.
+ // [#28412, ADR-0146 D9 item 1] …except the shipping package's
+ // declared override of a shipped flow name, judged on the row's
+ // own binding (the entry's `packageId`, its `package_id`
+ // column) and body: it is the packaged flow now, and the merge
+ // serves it in the package's slot (`servedOverride`).
const mergeable = overlays.filter(
- ({ data }) => !this.declinesStoredRow(request.type, (data as { name?: unknown } | null)?.name),
+ (entry) => !this.declinesStoredRow(request.type, (entry.data as { name?: unknown } | null)?.name, storedEntryRow(entry)),
);
// [#21804] Each row travels with its place (its stored
// spelling), so the merge picks a package's slot by the
@@ -9924,7 +9940,7 @@ export class ObjectStackProtocolImplementation implements
// [#21817] The package-less rows, placed the same way, as
// stand-ins: the same parse and the same stored-row rule.
const standIns = this.storedOverlayEntries(request, standInRows)
- .filter(({ data }) => !this.declinesStoredRow(request.type, (data as { name?: unknown } | null)?.name))
+ .filter((entry) => !this.declinesStoredRow(request.type, (entry.data as { name?: unknown } | null)?.name, storedEntryRow(entry)))
.map(({ data, type: recType }) => ({
data, packageId: undefined, stored: { type: recType }, standIn: true,
}));
@@ -9932,7 +9948,7 @@ export class ObjectStackProtocolImplementation implements
// the pair, as stand-ins: the merge serves one only where it
// declares the override of the slot's package.
const overrideStandIns = this.storedOverlayEntries(request, overrideRows)
- .filter(({ data }) => !this.declinesStoredRow(request.type, (data as { name?: unknown } | null)?.name))
+ .filter((entry) => !this.declinesStoredRow(request.type, (entry.data as { name?: unknown } | null)?.name, storedEntryRow(entry)))
.map(({ data, packageId: recPkg, type: recType }) => ({
data, packageId: recPkg, stored: { type: recType }, standIn: true,
}));
@@ -11362,7 +11378,11 @@ export class ObjectStackProtocolImplementation implements
}
storedRowServed = record !== undefined && record !== null;
// [#20946, #21922] The stored-row half — see `storedRowDeclined` above.
- if (record && !storedRowDeclined) {
+ // [#28412, ADR-0146 D9 item 1] Judged again on the row itself when
+ // the name declines: a shipped flow name's declared override, the
+ // row {@link findServedOverlayRow} serves first, is the packaged
+ // flow, served as the list's slot serves it ({@link declinesStoredRow}).
+ if (record && !(storedRowDeclined && this.declinesStoredRow(request.type, request.name, record as ServedOverrideRow))) {
item = this.convertStoredItem(
String(record.type ?? request.type),
storedRowDocument(record),
@@ -12006,6 +12026,10 @@ export class ObjectStackProtocolImplementation implements
// ── overlay layer: the environment's sys_metadata row (ADR-0131 D6) ──
let overlay: unknown | null = null;
let overlayScope: 'env' | null = null;
+ // [#28412] The stored row `overlay` was read from, when the served-row
+ // resolution read one: what {@link declinesStoredRow} judges a shipped
+ // flow name's declared override on.
+ let overlayRow: ServedOverrideRow | undefined;
// [#21761] The `overlay` layer of the one item-lock resolution below,
// selected from this read's address as in {@link getMetaItem}: the
// strictest lock among the item's stored rows in scope, never only the
@@ -12043,6 +12067,7 @@ export class ObjectStackProtocolImplementation implements
}, { otherSpelling: true });
if (served) {
const rec = served.row;
+ overlayRow = rec as ServedOverrideRow;
overlay = this.convertStoredItem(
String(rec.type ?? request.type),
storedRowDocument(rec),
@@ -12212,7 +12237,7 @@ export class ObjectStackProtocolImplementation implements
// same kind: `effective` is the code layer the by-name read serves.
const effectiveBase: unknown | null = expandedFrom !== undefined
? expandedFrom.item
- : overlay !== null && !orphaned && !this.declinesStoredRow(request.type, request.name)
+ : overlay !== null && !orphaned && !this.declinesStoredRow(request.type, request.name, overlayRow)
? this.foldObjectExtendersFromRegistry(request.type, request.name, overlay)
: code;
// [#21738] The lock is the one item-lock resolution's — the
@@ -16567,15 +16592,28 @@ export class ObjectStackProtocolImplementation implements
* is served as before. A door asks this method alone: ⛔ no door restates
* either half, or the host's code-datasource set. A write door does not
* ask it; the writes keep their own refusals.
- */
- declinesStoredRow(type: string, name: unknown): boolean {
+ *
+ * [#28412, ADR-0146 D9 item 1] `row` — the stored row the read is about to
+ * serve (its `type`, `package_id` column and body) — admits exactly one
+ * stored row of a shipped flow name: the shipping package's declared
+ * override, a row bound to that package's paired `.custom` whose
+ * body declares `overrides.package` = the package
+ * ({@link isServedOverrideRowOf}, the one predicate; ⛔ never the body's
+ * `_packageId`). That row is the packaged flow now, served over the
+ * loader's body for the name. Without `row`, or for any other row, the
+ * answer is the by-name one above. The flattened view, the by-name read,
+ * the layered read and the per-package customization read pass the row
+ * they serve.
+ */
+ declinesStoredRow(type: string, name: unknown, row?: ServedOverrideRow): boolean {
// [#25467 cut 10] The body moved to `protocol-lock-state.ts` (see `LockStateHost`).
const host: ThisParameterType = {
isDeclaredCodeDatasource: (...a) => this.isDeclaredCodeDatasource(...a),
isShippedFlowName: (...a) => this.isShippedFlowName(...a),
isUnservedSealedOverlay: (...a) => this.isUnservedSealedOverlay(...a),
+ packagedArtifactOwner: (...a) => this.packagedArtifactOwner(...a),
};
- return declinesStoredRow.call(host, type, name);
+ return declinesStoredRow.call(host, type, name, row);
}
/**
@@ -16744,7 +16782,17 @@ export class ObjectStackProtocolImplementation implements
const singular = PLURAL_TO_SINGULAR[folded.type] ?? folded.type;
if (singular !== 'flow') return null;
const shipped = this.packagedArtifactOwner(folded) !== undefined;
- if (shipped) {
+ // [#28412, ADR-0146 D2 / D9] Rule 1's lock does not apply to the
+ // shipping package's declared override: the one override write gate
+ // ({@link overrideWriteVerdict}) admits it as the pair's own row, which
+ // edits nothing the package ships. Any other write of a shipped name
+ // keeps the lock.
+ if (shipped && this.overrideWriteVerdict({
+ type: singular,
+ name: folded.name,
+ item: folded.item,
+ ...(folded.packageId ? { packageId: folded.packageId } : {}),
+ }).verdict !== 'admit') {
const locked = this.packagedBaseRefusal({
type: folded.type,
name: folded.name,
@@ -18623,11 +18671,14 @@ export class ObjectStackProtocolImplementation implements
// and its receipt rendered both as the package. Registered as the
// tenant row it is, it is reported as one and never armed.
const envelope = canonicalType === 'flow' ? undefined : artifact;
- registry.registerItem(
- type,
- mergeArtifactProtection(type, stateTenantAuthorship(withRowBinding(data, options.packageId)), envelope),
- 'name' as any,
- );
+ const registered = mergeArtifactProtection(type, stateTenantAuthorship(withRowBinding(data, options.packageId)), envelope);
+ // [#28412, ADR-0146 D9 item 1] …and a stored FLOW row's own binding,
+ // the `package_id` column, travels with the body the boot pull reads
+ // ({@link bindStoredRow}): the pull arms the shipping package's
+ // declared override over the loader's body only when the column, not
+ // a stamp in the body, binds the row to that package's `.custom` pair.
+ if (canonicalType === 'flow') bindStoredRow(registered, options.packageId ?? null);
+ registry.registerItem(type, registered, 'name' as any);
await this.hydrateExpandedViewItems(type, data, options);
return true;
}
@@ -28247,6 +28298,16 @@ export class ObjectStackProtocolImplementation implements
// {@link originGatedRemovalRefusal}: the item itself is never removed
// here, but a stored row under its name is, as repair.
const originGatedRefusal = this.originGatedRemovalRefusal(request.type, request.name);
+ // [#28412, ADR-0146 D9 item 1] A removal naming the shipping package's
+ // pair, of a type whose D9 conditions are delivered (`flow` only —
+ // every other open type keeps main's verdict): it removes
+ // the row the override is stored in, and the code package's item is
+ // served — and armed — again. Admitted past the sealed-removal refusal
+ // and told to the store as a runtime-only removal, as the declared
+ // save is ({@link isPairRowRemoval}).
+ const pairRowRemoval = isPairRowRemoval(
+ request.type, this.packagedArtifactOwner({ type: request.type, name: request.name }), request.packageId,
+ );
// Two-tier authorization for delete (mirrors saveMetaItem).
// • Artifact-backed item → delete becomes a tombstone overlay,
// requires `allowEnvironmentOverlay`…
@@ -28313,7 +28374,7 @@ export class ObjectStackProtocolImplementation implements
// [#21899] An origin-gated code-defined item's verdict is that same
// refusal, answered at the row probe instead of here: thrown when
// no stored row exists, and lifted for the repair that deletes one.
- if (originGatedRefusal === null) this.refusePackagedBaseRemoval(request);
+ if (originGatedRefusal === null && !pairRowRemoval) this.refusePackagedBaseRemoval(request);
if (!artifactBacked && !overlayAllowed && !runtimeCreateAllowed) {
const err = new Error(
`Metadata type '${request.type}' does not allow runtime creation or deletion.`
@@ -28336,6 +28397,7 @@ export class ObjectStackProtocolImplementation implements
// gate asks about it as it asks about every other allowed removal.
if (
originGatedRefusal !== null
+ || pairRowRemoval
|| this.packagedBaseRefusal({ type: request.type, name: request.name, operation: 'delete' }) === null
) {
const lockErr = await this.assertLockAllowsDelete({
@@ -28567,7 +28629,9 @@ export class ObjectStackProtocolImplementation implements
// #5927 — was an inline `this.isArtifactBacked(...)` call
// with these exact arguments; now reads the method-scoped
// binding the receipts share. Same fact, one call fewer.
- intent: artifactBacked
+ // [#28412] The pair's row of an open-override type is
+ // removed as the declared save wrote it: runtime-only.
+ intent: artifactBacked && !pairRowRemoval
? 'override-artifact'
: 'runtime-only',
state: targetState,
diff --git a/packages/metadata-protocol/src/stored-row-binding.ts b/packages/metadata-protocol/src/stored-row-binding.ts
new file mode 100644
index 00000000000..f745c0f7e92
--- /dev/null
+++ b/packages/metadata-protocol/src/stored-row-binding.ts
@@ -0,0 +1,77 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * # The stored row's own binding, carried on the body the registry holds (#28412)
+ *
+ * A stored `sys_metadata` row is bound to a package by its `package_id`
+ * COLUMN. The body the protocol hydrates into the SchemaRegistry for it
+ * (`hydrateOverlayIntoRegistry`) states it as `_packageId` since #28594
+ * (`storedOverlayEntries` serves the column and drops a body's own stamp),
+ * but `_packageId` is still no proof of the column: it is an ordinary JSON
+ * key, so a stored body, a request body or an import can carry one copied
+ * from a read of the base package (the shape #28238 measured), and every
+ * writer that does not go through that reader keeps it (`withRowBinding`
+ * keeps a body's own stamp).
+ *
+ * ADR-0146 D9 item 1 arms a flow over the code package's name only when the
+ * row that declares it is bound to the base's paired `.custom` — by the
+ * column, ⛔ never by the body. The automation boot pull reads the registry,
+ * not the store, so the column has to travel with the body: the hydrator
+ * records it here, and the reader asks {@link storedRowBindingOf}, then
+ * judges the row with the one served-override predicate
+ * (`isServedOverrideRowOf`, `served-override.ts`).
+ *
+ * ## Why a symbol, and why non-enumerable
+ *
+ * - **It cannot be authored.** A stored body is JSON: no parse of stored
+ * bytes, no request body and no import produces a symbol-keyed property, so
+ * a body cannot claim a binding the way it can claim a `_packageId`. Only
+ * the code that read the column writes it.
+ * - **It never leaves the process.** `JSON.stringify` drops it, so no
+ * response, export, checksum or history row carries it, and the shape of
+ * the served document is unchanged.
+ * - **It does not travel on a copy.** It is non-enumerable, so an object
+ * spread or a clone does not carry it: a body rebuilt from another (the
+ * list read's merges, a decorated copy) answers `undefined` — "no binding
+ * known", which admits nothing — rather than a binding a different object
+ * was read with.
+ *
+ * `Symbol.for`, so a second evaluation of this module (the dual ESM/CJS
+ * builds) reads the binding the first one wrote.
+ */
+
+const STORED_ROW_BINDING: unique symbol = Symbol.for('objectstack.metadata-protocol.storedRowBinding') as never;
+
+/**
+ * Record on `body` the `package_id` column of the stored row it was read
+ * from: a package id, or `null` for a row bound to no package. Mutates and
+ * returns `body`; a body that is not a plain object is returned unchanged.
+ *
+ * Only a reader of the stored row calls this — the registry hydration
+ * (`hydrateOverlayIntoRegistry`) — and a test standing in for it.
+ */
+export function bindStoredRow(body: T, packageId: unknown): T {
+ if (body === null || typeof body !== 'object' || Array.isArray(body)) return body;
+ const binding = typeof packageId === 'string' && packageId !== '' ? packageId : null;
+ Object.defineProperty(body, STORED_ROW_BINDING, {
+ value: binding,
+ enumerable: false,
+ writable: false,
+ configurable: true,
+ });
+ return body;
+}
+
+/**
+ * The `package_id` column of the stored row `item` was read from, as
+ * {@link bindStoredRow} recorded it: a package id, `null` for a package-less
+ * row, or `undefined` when `item` carries no recorded binding — a code
+ * artifact, a body no reader of the store bound, or a copy of one. ⛔ Never
+ * the body's `_packageId`.
+ */
+export function storedRowBindingOf(item: unknown): string | null | undefined {
+ if (item === null || typeof item !== 'object') return undefined;
+ if (!Object.prototype.hasOwnProperty.call(item, STORED_ROW_BINDING)) return undefined;
+ const binding = (item as { [STORED_ROW_BINDING]?: unknown })[STORED_ROW_BINDING];
+ return typeof binding === 'string' && binding !== '' ? binding : null;
+}
diff --git a/packages/objectql/src/protocol-flow-custom-override.test.ts b/packages/objectql/src/protocol-flow-custom-override.test.ts
new file mode 100644
index 00000000000..2cc16c6a5a0
--- /dev/null
+++ b/packages/objectql/src/protocol-flow-custom-override.test.ts
@@ -0,0 +1,344 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * [#28412, ADR-0146 D9 item 1] A flow a code package X ships, overridden by X's
+ * paired `.custom` declaring `overrides: { package: X }`, IS the packaged
+ * flow: the save door admits the declared save (N-09's write gate, opened for
+ * `flow`), the per-item publish promotes it, and every read of X's flow name
+ * serves it under X's provenance — the read naming no package, the read naming
+ * X, the layered read (`code` = X's flow, `effective` = the override), the
+ * served list and the execution view the automation engine arms from.
+ *
+ * Every other stored row of the name stays declined (#20946): a dependent
+ * package's declaration is refused at the save door and, laid down before the
+ * gate, is never served; the pair's UNDECLARED row is refused with a remedy
+ * that names the declaration, not a clone. Deleting the override serves X's
+ * flow again.
+ *
+ * The harness is `protocol-custom-override-effective-read.test.ts`'s (#28409):
+ * the real protocol over the real `ObjectQL` engine and an in-memory driver,
+ * every package installed through the package door. The protocol is
+ * `@objectstack/metadata-protocol`'s `dist/`, so a change there is read here
+ * after that package is rebuilt.
+ */
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import type { ServiceObject } from '@objectstack/spec/data';
+import {
+ ObjectStackProtocolImplementation,
+ isServedOverrideRowOf,
+ resetEnvWritableMetadataTypes,
+ storedRowBindingOf,
+ SysMetadataRepository,
+} from '@objectstack/metadata-protocol';
+import { SysMetadataAuditObject, SysMetadataCommitObject, SysMetadataHistoryObject } from '@objectstack/metadata-core';
+import { ObjectQL } from './engine.js';
+
+const BASE = 'com.example.showcase';
+const PAIR = 'com.example.showcase.custom';
+const DEPENDENT = 'com.default.acme-custom';
+const FLOW = 'showcase_task_escalate';
+const DECLARATION = { package: BASE, basedOn: '1.0.0' } as const;
+const MAPPING = 'showcase_import_map';
+const VIEW = 'showcase_task.default';
+
+const sysMetadataObject: ServiceObject = {
+ name: 'sys_metadata',
+ label: 'System Metadata',
+ fields: {
+ id: { name: 'id', label: 'ID', type: 'text' as const },
+ type: { name: 'type', label: 'Type', type: 'text' as const, required: true },
+ name: { name: 'name', label: 'Name', type: 'text' as const, required: true },
+ organization_id: { name: 'organization_id', label: 'Org', type: 'text' as const },
+ package_id: { name: 'package_id', label: 'Package', type: 'text' as const },
+ bundle_member: { name: 'bundle_member', label: 'Bundle Member', type: 'text' as const, maxLength: 100 },
+ metadata: { name: 'metadata', label: 'Body', type: 'textarea' as const },
+ draft_changes: { name: 'draft_changes', label: 'Draft Changes', type: 'textarea' as const },
+ checksum: { name: 'checksum', label: 'Checksum', type: 'text' as const, maxLength: 71 },
+ state: { name: 'state', label: 'State', type: 'text' as const },
+ version: { name: 'version', label: 'Version', type: 'number' as const },
+ created_at: { name: 'created_at', label: 'Created', type: 'datetime' as const },
+ updated_at: { name: 'updated_at', label: 'Updated', type: 'datetime' as const },
+ },
+};
+
+function makeMemoryDriver() {
+ const stores = new Map>>();
+ const storeFor = (obj: string) => {
+ let s = stores.get(obj);
+ if (!s) { s = new Map(); stores.set(obj, s); }
+ return s;
+ };
+ let nextId = 0;
+ const matchesWhere = (row: Record, where: any): boolean => {
+ if (!where || typeof where !== 'object') return true;
+ for (const [k, v] of Object.entries(where)) {
+ if (k === '$and' && Array.isArray(v)) {
+ if (!v.every((w: any) => matchesWhere(row, w))) return false;
+ continue;
+ }
+ if (k === '$or' && Array.isArray(v)) {
+ if (!v.some((w: any) => matchesWhere(row, w))) return false;
+ continue;
+ }
+ if (k.startsWith('$')) continue;
+ const rowVal = row[k];
+ if (v && typeof v === 'object' && Array.isArray((v as any).$in)) {
+ if (!(v as any).$in.includes(rowVal === undefined ? null : rowVal)) return false;
+ continue;
+ }
+ const expected = (v && typeof v === 'object' && '$eq' in (v as any)) ? (v as any).$eq : v;
+ const a = rowVal === undefined ? null : rowVal;
+ const b = expected === undefined ? null : expected;
+ if (a !== b) return false;
+ }
+ return true;
+ };
+ const driver: any = {
+ name: 'memory', version: '0.0.0', supports: {} as any,
+ async connect() {}, async disconnect() {}, async checkHealth() { return true; },
+ async execute() { return null; },
+ async find(object: string, ast: any) {
+ const rows = Array.from(storeFor(object).values()).filter((r) => matchesWhere(r, ast?.where));
+ return typeof ast?.limit === 'number' ? rows.slice(0, ast.limit) : rows;
+ },
+ async findOne(object: string, ast: any) {
+ for (const r of storeFor(object).values()) if (matchesWhere(r, ast?.where)) return r;
+ return null;
+ },
+ async create(object: string, data: Record) {
+ nextId += 1;
+ const id = (data.id as string) ?? `r_${nextId}`;
+ const row = { ...data, id };
+ storeFor(object).set(id, row);
+ return row;
+ },
+ async update(object: string, id: string, data: Record) {
+ const s = storeFor(object);
+ const cur = s.get(id);
+ if (!cur) throw new Error(`not found: ${object}/${id}`);
+ const updated = { ...cur, ...data, id };
+ s.set(id, updated);
+ return updated;
+ },
+ async upsert(object: string, data: Record) {
+ const id = data.id as string | undefined;
+ if (id && storeFor(object).has(id)) return this.update(object, id, data);
+ return this.create(object, data);
+ },
+ async delete(object: string, id: string) { return storeFor(object).delete(id); },
+ async count(object: string, ast: any) { return (await this.find(object, ast)).length; },
+ async bulkCreate(object: string, rows: Record[]) {
+ return Promise.all(rows.map((r) => this.create(object, r)));
+ },
+ async bulkUpdate() { return []; }, async bulkDelete() {},
+ async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; },
+ async commit() {}, async rollback() {},
+ };
+ return { driver, stores };
+}
+
+const flowBody = (label: string, extra: Record = {}) => ({
+ name: FLOW,
+ label,
+ type: 'autolaunched',
+ nodes: [
+ { id: 'start', type: 'start', label: 'Start', config: {} },
+ { id: 'end', type: 'end', label: 'End' },
+ ],
+ edges: [{ id: 'e1', source: 'start', target: 'end' }],
+ ...extra,
+});
+
+function baseManifest(): Record {
+ return {
+ id: BASE,
+ name: 'showcase',
+ version: '1.0.0',
+ namespace: 'showcase',
+ objects: [{
+ name: 'showcase_task',
+ label: 'Task',
+ sharingModel: 'private',
+ fields: { title: { name: 'title', label: 'title', type: 'text' } },
+ }],
+ flows: [flowBody('SHIPPED')],
+ mappings: [{ name: MAPPING, label: 'Import', sourceFormat: 'csv', targetObject: 'showcase_task', fieldMapping: [] }],
+ views: [{
+ object: 'showcase_task',
+ list: { label: 'All Tasks', type: 'grid', data: { provider: 'object', object: 'showcase_task' }, columns: [{ field: 'title' }] },
+ }],
+ };
+}
+
+async function boot(environmentId?: string) {
+ const engine = new ObjectQL();
+ const { driver, stores } = makeMemoryDriver();
+ engine.registerDriver(driver, true);
+ await engine.init();
+ engine.registry.registerObject(sysMetadataObject, 'test-package');
+ for (const o of [SysMetadataHistoryObject, SysMetadataAuditObject, SysMetadataCommitObject]) {
+ engine.registry.registerObject(o as any, 'test-package');
+ }
+ engine.registerApp(baseManifest());
+ const protocol = new ObjectStackProtocolImplementation(engine, () => new Map(), environmentId);
+ const rows = () => Array.from(stores.get('sys_metadata')?.values() ?? [])
+ .filter((r) => r.type === 'flow' && r.name === FLOW);
+ return { engine, protocol, rows };
+}
+
+const savedWritable = process.env.OS_METADATA_WRITABLE;
+beforeEach(() => {
+ delete process.env.OS_METADATA_WRITABLE;
+ ObjectStackProtocolImplementation.resetEnvWritableCache();
+ resetEnvWritableMetadataTypes();
+});
+afterEach(() => {
+ if (savedWritable === undefined) delete process.env.OS_METADATA_WRITABLE;
+ else process.env.OS_METADATA_WRITABLE = savedWritable;
+ ObjectStackProtocolImplementation.resetEnvWritableCache();
+ resetEnvWritableMetadataTypes();
+});
+
+async function install(protocol: ObjectStackProtocolImplementation, id: string): Promise {
+ await (protocol as any).installPackage({
+ manifest: {
+ id, name: id.split('.').join('-'), version: '0.1.0', type: 'app',
+ ...(id === PAIR
+ ? { namespace: 'showcase_custom', dependencies: { [BASE]: '^1' } }
+ : { dependencies: { [BASE]: '*' } }),
+ },
+ });
+}
+
+/** A draft save naming the package, then the per-item publish naming it. */
+async function saveInto(protocol: ObjectStackProtocolImplementation, packageId: string, label: string, extra: Record = {}) {
+ await protocol.saveMetaItem({ type: 'flow', name: FLOW, item: flowBody(label, extra), mode: 'draft', packageId } as any);
+ await protocol.publishMetaItem({ type: 'flow', name: FLOW, packageId } as any);
+}
+
+const read = async (protocol: ObjectStackProtocolImplementation, packageId?: string) =>
+ ((await protocol.getMetaItem({ type: 'flow', name: FLOW, ...(packageId ? { packageId } : {}) })) as any).item;
+const layered = async (protocol: ObjectStackProtocolImplementation, packageId?: string): Promise =>
+ (protocol as any).getMetaItemLayered({ type: 'flow', name: FLOW, ...(packageId ? { packageId } : {}) });
+const listed = async (protocol: ObjectStackProtocolImplementation, face: 'served' | 'execution') => {
+ const res: any = face === 'served'
+ ? await protocol.getMetaItems({ type: 'flow' })
+ : await (protocol as any).getMetaItemsForExecution({ type: 'flow' });
+ return (res.items as any[]).filter((i) => i?.name === FLOW).map((i) => [i._packageId, i.label]);
+};
+
+describe('[#28412] a declared .custom flow override is the packaged flow, at every door', () => {
+ it('the save door admits the declared save, the publish promotes it, and every read serves it under X', async () => {
+ const { protocol, engine, rows } = await boot();
+ await install(protocol, PAIR);
+ await saveInto(protocol, PAIR, 'OVERRIDE', { overrides: { ...DECLARATION } });
+ expect(rows().map((r) => [r.package_id, r.state])).toEqual([[PAIR, 'active']]);
+ expect(isServedOverrideRowOf(rows()[0] as any, BASE)).toBe(true);
+
+ for (const scope of [undefined, BASE]) {
+ const item = await read(protocol, scope);
+ expect(item.label, `by-name ${scope ?? '(no package)'}`).toBe('OVERRIDE');
+ expect(item._packageId).toBe(BASE);
+ expect(item.overrides).toEqual(DECLARATION);
+ const layers = await layered(protocol, scope);
+ expect(layers.code.label, `layered ${scope ?? '(no package)'} code`).toBe('SHIPPED');
+ expect(layers.effective.label, `layered ${scope ?? '(no package)'} effective`).toBe('OVERRIDE');
+ }
+ expect(await listed(protocol, 'served')).toEqual([[BASE, 'OVERRIDE']]);
+ expect(await listed(protocol, 'execution')).toEqual([[BASE, 'OVERRIDE']]);
+
+ // What the automation boot pull reads: the registry's hydrated copy
+ // carries the row's own `package_id`, the binding precedence judges.
+ const hydrated = (engine.registry.listItems('flow') as any[]).filter((i) => i?.name === FLOW);
+ expect(hydrated.map((i) => storedRowBindingOf(i))).toContain(PAIR);
+ });
+
+ it('a dependent package\'s declaration is refused at the save door, and laid down before the gate is never served', async () => {
+ const { protocol, engine, rows } = await boot();
+ await install(protocol, DEPENDENT);
+ await expect(protocol.saveMetaItem({
+ type: 'flow', name: FLOW, item: flowBody('DEPENDENT', { overrides: { ...DECLARATION } }), mode: 'draft', packageId: DEPENDENT,
+ } as any)).rejects.toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 });
+
+ // Laid down as one stored before the gate, by the store's own write.
+ await engine.insert('sys_metadata', {
+ type: 'flow', name: FLOW, organization_id: null, package_id: DEPENDENT,
+ metadata: JSON.stringify(flowBody('DEPENDENT', { overrides: { ...DECLARATION } })), state: 'active', version: 1,
+ }, { context: { isSystem: true } });
+ expect(rows().map((r) => r.package_id)).toEqual([DEPENDENT]);
+ expect((await read(protocol)).label).toBe('SHIPPED');
+ expect((await read(protocol, BASE)).label).toBe('SHIPPED');
+ expect(await listed(protocol, 'execution')).toEqual([[BASE, 'SHIPPED']]);
+ });
+
+ it('the pair\'s undeclared save is refused, and the remedy names the declaration, never a clone', async () => {
+ const { protocol } = await boot();
+ await install(protocol, PAIR);
+ const refusal = await protocol.saveMetaItem({
+ type: 'flow', name: FLOW, item: flowBody('UNDECLARED'), mode: 'draft', packageId: PAIR,
+ } as any).then(() => undefined, (e: Error) => e);
+ expect(refusal).toMatchObject({ status: 403 });
+ expect(refusal!.message).toMatch(/[Dd]eclare/);
+ expect(refusal!.message).not.toMatch(/clone/i);
+ expect(refusal!.message).not.toMatch(/not yet available/);
+ });
+
+ it('deleting the override serves X\'s flow again', async () => {
+ const { protocol, rows } = await boot();
+ await install(protocol, PAIR);
+ await saveInto(protocol, PAIR, 'OVERRIDE', { overrides: { ...DECLARATION } });
+ expect(await listed(protocol, 'execution')).toEqual([[BASE, 'OVERRIDE']]);
+ await protocol.deleteMetaItem({ type: 'flow', name: FLOW, packageId: PAIR } as any);
+ expect(rows().filter((r) => r.state === 'active')).toEqual([]);
+ expect((await read(protocol)).label).toBe('SHIPPED');
+ expect(await listed(protocol, 'execution')).toEqual([[BASE, 'SHIPPED']]);
+ });
+});
+
+/**
+ * [#28412, contract review 6559358939] The pair-row removal is admitted for
+ * `flow` alone — the one type whose ADR-0146 D9 conditions this change
+ * delivers. Every other open override type keeps the removal verdict and the
+ * receipt intent it has on main: a `mapping` (no overlay channel, outside the
+ * #6960 carve-out) is still refused, and a `view` (overlay-capable) is still
+ * removed as an `override-artifact` delete.
+ */
+describe('[#28412] the pair-row removal is flow-only: other open types keep main\'s verdict', () => {
+ const storePairRow = (engine: ObjectQL, type: string, name: string, body: Record) =>
+ engine.insert('sys_metadata', {
+ type, name, organization_id: null, package_id: PAIR,
+ metadata: JSON.stringify({ ...body, overrides: { ...DECLARATION } }), state: 'active', version: 1,
+ }, { context: { isSystem: true } });
+ const rowsOf = async (engine: ObjectQL, type: string, name: string) =>
+ ((await engine.find('sys_metadata', { where: { type, name }, context: { isSystem: true } })) as any[]);
+
+ for (const environmentId of [undefined, 'env_1']) {
+ const kernel = environmentId ? 'environment' : 'host-config';
+ it(`a mapping's pair-row delete naming the pair still answers 403 NOT_OVERRIDABLE, and the row stays (${kernel} kernel)`, async () => {
+ const { protocol, engine } = await boot(environmentId);
+ await install(protocol, PAIR);
+ await storePairRow(engine, 'mapping', MAPPING, { name: MAPPING, label: 'Ours', sourceFormat: 'csv', targetObject: 'showcase_task', fieldMapping: [] });
+ await expect(protocol.deleteMetaItem({ type: 'mapping', name: MAPPING, packageId: PAIR } as any))
+ .rejects.toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 });
+ expect((await rowsOf(engine, 'mapping', MAPPING)).map((r) => r.package_id)).toEqual([PAIR]);
+ });
+ }
+
+ it('a view\'s pair-row delete naming the pair is removed as an override-artifact delete, as on main', async () => {
+ const { protocol, engine } = await boot();
+ await install(protocol, PAIR);
+ await storePairRow(engine, 'view', VIEW, {
+ name: VIEW, object: 'showcase_task', viewKind: 'list', label: 'Ours',
+ config: { type: 'grid', data: { provider: 'object', object: 'showcase_task' }, columns: [{ field: 'title' }] },
+ });
+ const removal = vi.spyOn(SysMetadataRepository.prototype, 'delete');
+ try {
+ await protocol.deleteMetaItem({ type: 'view', name: VIEW, packageId: PAIR } as any);
+ expect(removal).toHaveBeenCalledTimes(1);
+ expect(removal.mock.calls[0][1]).toMatchObject({ intent: 'override-artifact', packageId: PAIR });
+ } finally {
+ removal.mockRestore();
+ }
+ expect((await rowsOf(engine, 'view', VIEW)).filter((r) => r.state === 'active')).toEqual([]);
+ });
+});
diff --git a/packages/rest/src/rest-meta-packaged-flow-refusal.test.ts b/packages/rest/src/rest-meta-packaged-flow-refusal.test.ts
index 6849e1ae7a0..fcd0c36c4d8 100644
--- a/packages/rest/src/rest-meta-packaged-flow-refusal.test.ts
+++ b/packages/rest/src/rest-meta-packaged-flow-refusal.test.ts
@@ -7,7 +7,8 @@
* ADR-0126 §2: in a Regime C type "the packaged base is locked — in-place edit
* refused loudly at the write door, the refusal naming the sanctioned path".
* For a flow those paths are the clone under a new name (§7.1,
- * `POST /api/v1/automation/:name/clone`) and the enable/disable switch (§7.2,
+ * `POST /api/v1/automation/:name/clone`; [#28412] for a save, the declared
+ * override from the paired `.custom` instead) and the enable/disable switch (§7.2,
* `POST /api/v1/automation/:name/toggle`, operator-gated per §5). The sentence
* is the metadata protocol's, chosen per regime there; the protocol-side pins
* are `packages/metadata-protocol/src/protocol.packaged-base-refusal.test.ts`.
@@ -78,9 +79,17 @@ function boot(environmentId: string | undefined) {
return { call };
}
-const expectRegimeC = (message: unknown) => {
+const expectRegimeC = (message: unknown, verb: 'save' | 'delete') => {
const text = String(message);
- expect(text).toContain('POST /api/v1/automation/:name/clone');
+ // [#28412, ADR-0146 D9] A save names the declared override from the
+ // package's paired `.custom` in place of the clone (a second flow beside
+ // the base, both running); the removal still names the clone.
+ if (verb === 'save') {
+ expect(text).toContain(`Declare an override from '${PACKAGE_ID}.custom'`);
+ expect(text).not.toContain('POST /api/v1/automation/:name/clone');
+ } else {
+ expect(text).toContain('POST /api/v1/automation/:name/clone');
+ }
expect(text).toContain('POST /api/v1/automation/:name/toggle');
expect(text).toContain('docs/adr/0126-packaged-metadata-customization-model.md');
expect(text).not.toContain('OS_METADATA_WRITABLE');
@@ -95,7 +104,7 @@ describe('PUT / DELETE /api/v1/meta/flow/:name on a packaged flow — the refusa
const r = await call('PUT', 'flow', PACKAGED_FLOW, { name: PACKAGED_FLOW, label: 'Changed in place' });
expect(r.status).toBe(403);
expect(r.code).toBe('NOT_OVERRIDABLE');
- expectRegimeC(r.message);
+ expectRegimeC(r.message, 'save');
});
}
@@ -104,7 +113,7 @@ describe('PUT / DELETE /api/v1/meta/flow/:name on a packaged flow — the refusa
const r = await call('DELETE', 'flow', PACKAGED_FLOW);
expect(r.status).toBe(403);
expect(r.code).toBe('NOT_OVERRIDABLE');
- expectRegimeC(r.message);
+ expectRegimeC(r.message, 'delete');
});
it('control: a packaged `page` (no declared regime) reads the one sealed-write sentence (ADR-0131 D6)', async () => {
diff --git a/packages/runtime/src/domains/automation-packaged-base-lock.test.ts b/packages/runtime/src/domains/automation-packaged-base-lock.test.ts
index b37c0e611ad..78bc5d1e0ea 100644
--- a/packages/runtime/src/domains/automation-packaged-base-lock.test.ts
+++ b/packages/runtime/src/domains/automation-packaged-base-lock.test.ts
@@ -169,14 +169,23 @@ const errorOf = (response: any): { code?: unknown; message?: unknown } => respon
/**
* [#20819, ADR-0126 §2] The relayed refusal NAMES Regime C's sanctioned paths —
- * the clone under a new name (§7.1) and the enable/disable switch (§7.2) — and
+ * the clone under a new name (§7.1; [#28412] for a save, the declared override
+ * from the paired `.custom` instead) and the enable/disable switch (§7.2) — and
* neither the `OS_METADATA_WRITABLE` hatch nor a redeploy. The sentence is the
* metadata protocol's (`packagedBaseRefusal`, chosen per regime there); this
* asserts it arrives at these doors, not its wording.
*/
-function expectRegimeCPrescription(response: any): void {
+function expectRegimeCPrescription(response: any, verb: 'save' | 'delete'): void {
const message = String(errorOf(response).message);
- expect(message).toContain('POST /api/v1/automation/:name/clone');
+ // [#28412, ADR-0146 D9] A save names the declared override from the
+ // package's paired `.custom` in place of the clone: the clone is a second
+ // flow beside the base, and both run. The removal still names the clone.
+ if (verb === 'save') {
+ expect(message).toContain(`Declare an override from '${PACKAGE_ID}.custom'`);
+ expect(message).not.toContain('POST /api/v1/automation/:name/clone');
+ } else {
+ expect(message).toContain('POST /api/v1/automation/:name/clone');
+ }
expect(message).toContain('POST /api/v1/automation/:name/toggle');
expect(message).toContain('docs/adr/0126-packaged-metadata-customization-model.md');
expect(message).not.toContain('OS_METADATA_WRITABLE');
@@ -201,7 +210,7 @@ describe('a PACKAGED flow — the base is locked at both /automation definition
expect(statusOf(response)).toBe(403);
expect(errorOf(response).code).toBe('NOT_OVERRIDABLE');
- expectRegimeCPrescription(response);
+ expectRegimeCPrescription(response, 'save');
// Refuse first, mutate never: the engine was not entered, and
// the live definition is the one that was there before.
expect(h.registerFlow).not.toHaveBeenCalled();
@@ -221,7 +230,7 @@ describe('a PACKAGED flow — the base is locked at both /automation definition
expect(statusOf(response)).toBe(403);
expect(errorOf(response).code).toBe('NOT_OVERRIDABLE');
- expectRegimeCPrescription(response);
+ expectRegimeCPrescription(response, 'delete');
expect(h.unregisterFlow).not.toHaveBeenCalled();
const read = await h.dispatcher.handleAutomation(`/${PACKAGED}`, 'GET', undefined, AUTHOR(), undefined);
@@ -272,7 +281,7 @@ describe('a PACKAGED flow — the base is locked at both /automation definition
);
expect(statusOf(overwrite.response)).toBe(403);
expect(errorOf(overwrite.response).code).toBe('NOT_OVERRIDABLE');
- expectRegimeCPrescription(overwrite.response);
+ expectRegimeCPrescription(overwrite.response, 'save');
expect(h.registerFlow).not.toHaveBeenCalled();
expect(h.held(PACKAGED)).toBe(before);
diff --git a/packages/services/service-automation/src/engine.ts b/packages/services/service-automation/src/engine.ts
index b0d510c36e9..b9dbba7dd60 100644
--- a/packages/services/service-automation/src/engine.ts
+++ b/packages/services/service-automation/src/engine.ts
@@ -2539,14 +2539,19 @@ export function graftConditionEnvelopes(converted: unknown, parsed: unknown): un
/**
* [#11997] Where one flow body came from, as the boot pull could tell.
*
+ * `'override'` — [#28412, ADR-0146 D9 item 1] the shipping package's declared
+ * override: a stored row bound, by its own `package_id`, to that package's
+ * paired `.custom`, whose body declares `overrides.package` = the
+ * package. `packageId` is the pair. It is the packaged flow now, armed over
+ * the loader's body for the name.
* `'package'` — a code-shipped artifact (`isCodeArtifactBody`, ADR-0029 D9.6).
* `'runtime'` — a `sys_metadata`/runtime-authored row with no real package
* provenance: the ADR-0005 overlay, and the sanctioned override path per
* ADR-0048 §1.5.
*/
export interface FlowContender {
- source: 'package' | 'runtime';
- /** The owning package id, when a code package ships this body. */
+ source: 'override' | 'package' | 'runtime';
+ /** The owning package id, when a code package ships this body; the `.custom` pair for an override. */
packageId?: string;
}
diff --git a/packages/services/service-automation/src/flow-name-shadowing.test.ts b/packages/services/service-automation/src/flow-name-shadowing.test.ts
index 18946474909..a395ae6fd3c 100644
--- a/packages/services/service-automation/src/flow-name-shadowing.test.ts
+++ b/packages/services/service-automation/src/flow-name-shadowing.test.ts
@@ -31,6 +31,7 @@ import { describe, it, expect, vi } from 'vitest';
import { SchemaRegistry } from '@objectstack/objectql';
import { AutomationEngine } from './engine.js';
import { resolveFlowPrecedence, describeFlowContender, renderFlowContender } from './flow-precedence.js';
+import { bindStoredRow } from '@objectstack/metadata-protocol';
const FLOW = 'opportunity_approval';
const silentLogger = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() } as any;
@@ -305,3 +306,59 @@ describe('#12563 — renderFlowContender: one spelling for the contested-flow ph
);
});
});
+
+/**
+ * [#28412, ADR-0146 D9 item 1] The one stored row the sealed base yields to:
+ * the shipping package's declared `.custom` override. Over a real
+ * `SchemaRegistry` holding the loader's body under the package and the stored
+ * row in the bare slot — the way `hydrateOverlayIntoRegistry` registers it,
+ * its `package_id` column recorded with `bindStoredRow` — the boot pull arms
+ * the override, warns nothing and records no shadowing, in either
+ * registration order. A stored row bound anywhere else stays shadowed, as
+ * above.
+ */
+describe('[#28412] the declared .custom override is the packaged flow over a real registry', () => {
+ const OWNER = 'com.example.crm';
+ const PAIR = `${OWNER}.custom`;
+ const DECLARED = { overrides: { package: OWNER, basedOn: '1.0.0' } };
+ const loaderBody = () => ({ ...flowBody(FLOW, 'PACKAGED'), _packageId: OWNER });
+ const hydrated = (binding: string | null, body: Record = DECLARED) =>
+ bindStoredRow({ ...flowBody(FLOW, 'OVERRIDE'), ...body, _provenance: 'org' }, binding);
+
+ function registryWith(stored: unknown, order: 'package-first' | 'stored-first') {
+ const registry: any = new SchemaRegistry();
+ if (order === 'package-first') {
+ registry.registerItem('flow', loaderBody(), 'name', OWNER);
+ registry.registerItem('flow', stored, 'name');
+ } else {
+ registry.registerItem('flow', stored, 'name');
+ registry.registerItem('flow', loaderBody(), 'name', OWNER);
+ }
+ return registry;
+ }
+
+ for (const order of ['package-first', 'stored-first'] as const) {
+ it(`${order}: the pair's declared row is armed, with no warning and no shadowing receipt`, async () => {
+ const warn = vi.fn();
+ const { engine, resolved } = bootPull(registryWith(hydrated(PAIR), order), { warn });
+ expect(resolved.map((r) => r.name)).toEqual([FLOW]);
+ expect(((await engine.getFlow(FLOW)) as { label?: string } | null)?.label).toBe('OVERRIDE');
+ expect(warn).not.toHaveBeenCalled();
+ expect(engine.getShadowedFlows()).toEqual([]);
+ // The ledger identity is the base's: the name stays packaged by OWNER.
+ expect(engine.packagedFlowOwner(FLOW)).toBe(OWNER);
+ });
+ }
+
+ for (const [label, stored] of [
+ ['a dependent package\'s declaration', () => hydrated('com.example.partner')],
+ ['a package-less row stamping the pair', () => hydrated(null, { ...DECLARED, _packageId: PAIR })],
+ ['the pair\'s undeclared row', () => hydrated(PAIR, {})],
+ ] as const) {
+ it(`${label}: the loader's body stays armed and the row is shadowed`, async () => {
+ const { engine } = bootPull(registryWith(stored(), 'package-first'));
+ expect(((await engine.getFlow(FLOW)) as { label?: string } | null)?.label).toBe('PACKAGED');
+ expect(engine.getShadowedFlows().map((r) => r.armed)).toEqual([{ source: 'package', packageId: OWNER }]);
+ });
+ }
+});
diff --git a/packages/services/service-automation/src/flow-precedence-loader-set.test.ts b/packages/services/service-automation/src/flow-precedence-loader-set.test.ts
index aadbed4f54d..918d57ff43e 100644
--- a/packages/services/service-automation/src/flow-precedence-loader-set.test.ts
+++ b/packages/services/service-automation/src/flow-precedence-loader-set.test.ts
@@ -21,6 +21,7 @@ import type { Plugin, PluginContext } from '@objectstack/core';
import { AutomationServicePlugin } from './plugin.js';
import type { AutomationEngine, FlowContender } from './engine.js';
import { resolveFlowPrecedence, describeFlowContender, renderFlowContender } from './flow-precedence.js';
+import { bindStoredRow } from '@objectstack/metadata-protocol';
const FLOW = 'order_sync';
@@ -147,6 +148,81 @@ describe('[#20864] boot-time precedence classifies by the loader\'s set, not by
});
});
+// ── [#28412] The declared .custom override ranks above the package ─────────
+
+/**
+ * [#28412, ADR-0146 D9 item 1] The shipping package's declared override is
+ * the packaged flow: a stored row whose STORED binding (`bindStoredRow`, what
+ * the protocol's hydration records from the `package_id` column) is the
+ * owner's paired `.custom`, and whose body declares `overrides.package` = the
+ * owner, ranks `override`, above the loader's body. Nothing else does — not a
+ * dependent's declaration, not a body stamped with the pair, not the pair's
+ * undeclared row.
+ */
+describe('[#28412] the declared .custom override is armed over the loader\'s body', () => {
+ const OWNER = 'com.example.crm';
+ const PAIR = `${OWNER}.custom`;
+ const DECLARED = { overrides: { package: OWNER, basedOn: '1.0.0' } };
+ const HOLDS = setOf({ [FLOW]: OWNER });
+ const base = () => flowBody(FLOW, 'LOADER', { _packageId: OWNER, _provenance: 'package' });
+ /** A stored row as the hydration registers it: tenant-marked, its column recorded. */
+ const stored = (binding: string | null, body: Record = DECLARED, label = 'OVERRIDE') =>
+ bindStoredRow(flowBody(FLOW, label, { ...body, _provenance: 'org' }), binding);
+
+ it('classifies the pair\'s declared row as the override, by its stored binding', () => {
+ expect(describeFlowContender(stored(PAIR), HOLDS)).toEqual({ source: 'override', packageId: PAIR });
+ expect(renderFlowContender({ source: 'override', packageId: PAIR })).toBe(`the declared override in '${PAIR}' (sys_metadata)`);
+ });
+
+ it('arms the override in either arrival order, with no warning and no receipt', () => {
+ for (const listed of [[base(), stored(PAIR)], [stored(PAIR), base()]]) {
+ const logger = silent();
+ const winners = resolveFlowPrecedence(listed, logger, HOLDS);
+ expect(winners).toHaveLength(1);
+ expect((winners[0].definition as { label: string }).label).toBe('OVERRIDE');
+ expect(winners[0].shadowing).toBeUndefined();
+ expect(logger.warn).not.toHaveBeenCalled();
+ }
+ });
+
+ for (const [label, row] of [
+ ['a dependent package\'s declaration', () => stored('com.example.partner')],
+ ['a package-less row whose body stamps the pair', () => stored(null, { ...DECLARED, _packageId: PAIR })],
+ ['a body that only CLAIMS the pair (no stored binding)', () => flowBody(FLOW, 'OVERRIDE', { ...DECLARED, _packageId: PAIR, _provenance: 'org' })],
+ ['the pair\'s undeclared row', () => stored(PAIR, {})],
+ ['another base\'s pair declaring this owner', () => stored('com.other.custom')],
+ ] as const) {
+ it(`${label} is not armed: the loader's body is, and the remedy names the declaration, not a clone`, () => {
+ const logger = silent();
+ const [winner] = resolveFlowPrecedence([row(), base()], logger, HOLDS);
+ expect((winner.definition as { label: string }).label).toBe('LOADER');
+ expect(winner.shadowing?.armed).toEqual({ source: 'package', packageId: OWNER });
+ expect(winner.shadowing?.shadowed.map((c) => c.source)).toEqual(['runtime']);
+ const [message] = logger.warn.mock.calls[0] as [string];
+ expect(message).toContain(`declare it as an override in '${PAIR}' (overrides: { package: '${OWNER}', basedOn })`);
+ expect(message).toContain('or save it under a new name');
+ expect(message).not.toMatch(/clone/i);
+ });
+ }
+
+ it('an unheld name: a declared row is tenant-authored, the set vouches for no override', () => {
+ expect(describeFlowContender(stored(PAIR), HOLDS_NOTHING).source).toBe('runtime');
+ });
+
+ it('the override beside an undeclared stored row: the override is armed, the stored row shadowed and warned', () => {
+ const logger = silent();
+ const [winner] = resolveFlowPrecedence([stored(null, {}, 'TENANT'), base(), stored(PAIR)], logger, HOLDS);
+ expect((winner.definition as { label: string }).label).toBe('OVERRIDE');
+ expect(winner.shadowing).toEqual({
+ name: FLOW,
+ armed: { source: 'override', packageId: PAIR },
+ shadowed: [{ source: 'package', packageId: OWNER }, { source: 'runtime' }],
+ });
+ expect(logger.warn).toHaveBeenCalledTimes(1);
+ expect((logger.warn.mock.calls[0] as [string])[0]).toContain(`because it is the declared override of '${OWNER}' (ADR-0146 D9)`);
+ });
+});
+
// ── The boot pull, through the real plugin ─────────────────────────────────
/** The one seam the boot pull reads — `registry.listItems('flow')`. */
diff --git a/packages/services/service-automation/src/flow-precedence.ts b/packages/services/service-automation/src/flow-precedence.ts
index 272fbf36d87..0a8f957e13e 100644
--- a/packages/services/service-automation/src/flow-precedence.ts
+++ b/packages/services/service-automation/src/flow-precedence.ts
@@ -57,6 +57,26 @@
// reader ask one source); with none, NO contender is packaged, which is the
// engine's fail-closed answer too. The body's stamps stay on the contender
// for display only.
+//
+// [#28412, ADR-0146 D9 item 1] THE DECLARED OVERRIDE IS THE PACKAGED FLOW.
+//
+// ADR-0146 D2 gives a base package's customer one place to change a base
+// item: an item of the base's paired `.custom` package that bears the
+// base item's name and declares `overrides: { package: , basedOn }`
+// (ruling A on #28390: only the pair declares). D9 item 1 applies that to a
+// flow: the declared override is armed OVER the loader's body for the name,
+// so a customer's change to HotCRM's flow is HotCRM's flow — one body runs
+// per record change, the same ledger switch, and deleting it restores the
+// original. So the rank gains one case above `package`, and only one row can
+// hold it: a contender is `override` exactly when the loader's set holds its
+// name and the one served-override predicate (`isServedOverrideRowOf`,
+// `@objectstack/metadata-protocol`) admits it for the set's owner, judged on
+// the row's STORED binding — the `package_id` column the protocol read,
+// carried on the body by the protocol's hydration (`storedRowBindingOf`) —
+// ⛔ never on the body's `_packageId`. A dependent package's declaration, a
+// package-less row whose body names the pair, and an undeclared pair row are
+// `runtime` as before: shadowed and warned, with a remedy that names the
+// declaration or a new name.
// [commit fa5d137ab] From `@objectstack/metadata-core`, which this package DECLARES —
// not from `@objectstack/objectql`, which it does not. The predicate is the
@@ -66,6 +86,12 @@
// answered it by inlining objectql's implementation into this package's dist.
// `pnpm check:undeclared-dep-imports` is the gate over that class.
import { isCodeArtifactBody } from '@objectstack/metadata-core';
+// [#28412, ADR-0146 D9 item 1] The one served-override predicate (N-10) and
+// the stored row's own binding the protocol's hydration records — never a
+// second spelling of "is this the declared override", and never the body's
+// `_packageId`.
+import { isServedOverrideRowOf, storedRowBindingOf } from '@objectstack/metadata-protocol';
+import { customizationPackageId } from '@objectstack/spec/kernel';
import type { FlowContender, FlowShadowingRecord, PackagedFlowSource } from './engine.js';
/** One flow name's resolved winner, plus the receipt when it displaced others. */
@@ -117,6 +143,14 @@ function loaderSetOwner(packagedFlowOwner: PackagedFlowSource | undefined, name:
*/
function classifyContender(item: unknown, owner: string | undefined): FlowContender {
const packageId = (item as { _packageId?: unknown } | null | undefined)?._packageId;
+ // [#28412, ADR-0146 D9 item 1] Asked FIRST, and only inside a held name:
+ // the owner's declared override, by its stored binding (see the header).
+ if (owner !== undefined) {
+ const binding = storedRowBindingOf(item);
+ if (isServedOverrideRowOf({ type: 'flow', package_id: binding, metadata: item }, owner)) {
+ return { source: 'override', packageId: binding as string };
+ }
+ }
if (owner !== undefined && isCodeArtifactBody(item)) {
return { source: 'package', packageId: String(packageId) };
}
@@ -184,6 +218,11 @@ export function describeFlowContender(item: unknown, packagedFlowOwner?: Package
* only because of who happens to call it stops being safe at the next caller.
*/
export function renderFlowContender(contender: FlowContender): string {
+ // [#28412] The declared override is a stored row too, and the phrase says
+ // which: the pair it is bound to (its stored binding, always present).
+ if (contender.source === 'override') {
+ return `the declared override in '${contender.packageId}' (sys_metadata)`;
+ }
if (contender.source !== 'package') return 'a runtime-authored row (sys_metadata)';
return contender.packageId
? `package '${contender.packageId}'`
@@ -193,13 +232,35 @@ export function renderFlowContender(contender: FlowContender): string {
/**
* Rank one contender for a bare name. LOWER wins.
*
- * [#20913] `package` (0) beats `runtime` (1) — ADR-0126 §2's sealed base,
- * quoted at the top of this file. A `package` contender exists only inside a
- * name the loader's set holds, so this rank decides exactly one thing: a
- * stored row of a shipped name is shadowed, never armed.
+ * [#28412, ADR-0146 D9 item 1] `override` (0) beats `package` (1): the
+ * shipping package's declared override is the packaged flow, armed over the
+ * loader's body. [#20913] `package` (1) beats `runtime` (2) — ADR-0126 §2's
+ * sealed base, quoted at the top of this file. An `override` or a `package`
+ * contender exists only inside a name the loader's set holds, so this rank
+ * decides exactly two things there: the declared override is armed, and every
+ * other stored row of a shipped name is shadowed, never armed.
*/
function precedenceRank(contender: FlowContender): number {
- return contender.source === 'package' ? 0 : 1;
+ switch (contender.source) {
+ case 'override': return 0;
+ case 'package': return 1;
+ default: return 2;
+ }
+}
+
+/**
+ * [#28412, ADR-0146 D9 item 1] The remedy for a stored row of a shipped name
+ * that is not the declared override: declare it from the shipping package's
+ * paired `.custom` (the one row the rank arms), or save it under a new name.
+ * ⛔ No longer "clone": the clone is a second flow beside the base, and both
+ * run.
+ */
+function undeclaredRemedy(owner: string | undefined): string {
+ const pair = owner === undefined ? undefined : customizationPackageId(owner);
+ const where = pair?.ok
+ ? `declare it as an override in '${pair.value}' (overrides: { package: '${owner}', basedOn })`
+ : 'declare it as an override in the shipping package\'s paired .custom package';
+ return `To change what runs, ${where}, or save it under a new name.`;
}
/**
@@ -210,8 +271,10 @@ function precedenceRank(contender: FlowContender): number {
* more than one contender are reordered — and those by a TOTAL order that does
* not read registry iteration order at all:
*
- * 1. `package` before `runtime` (ADR-0126 §2: a managed package's flow is
- * sealed, so a stored row of a name it ships is shadowed);
+ * 1. `override` before `package` before `runtime` ([#28412] ADR-0146 D9
+ * item 1: the shipping package's declared override is the packaged flow;
+ * ADR-0126 §2: a managed package's flow is sealed, so any other stored
+ * row of a name it ships is shadowed);
* 2. within `package`, lexicographic `packageId`.
*
* Rule 2 covers the ADR-0048 §3.4 case of two packages legitimately shipping one
@@ -293,17 +356,32 @@ export function resolveFlowPrecedence(
const armed = ranked[0];
const shadowed = ranked.slice(1).map((entry) => entry.contender);
- // [#20913] Say WHY this one is armed, per case — the three cases are
- // three different rules, and the remedy differs with them.
+ // [#28412, ADR-0146 D9 item 1] The declared override over its own
+ // base's body, and nothing else: the customized state, not a
+ // collision. Nothing is warned and no receipt is written — the name
+ // has one packaged flow, the override — exactly as for a name with one
+ // contender. Any other contender beside them (a second package
+ // shipping the name, an undeclared stored row) keeps the warning.
+ const overrideOverOwnBase = armed.contender.source === 'override'
+ && shadowed.every((contender) => contender.source === 'package' && contender.packageId === owner);
+ if (overrideOverOwnBase) {
+ winners.push({ name, definition: armed.definition });
+ continue;
+ }
+ // [#20913] Say WHY this one is armed, per case — the cases are
+ // different rules, and the remedy differs with them.
const sealedOverStored =
- armed.contender.source === 'package' && shadowed.some((contender) => contender.source === 'runtime');
- const why = sealedOverStored
- ? ` because a managed package's flow is sealed (ADR-0126 §2)`
- : armed.contender.source === 'package'
- ? ' by package id'
- : ' by arrival order';
+ (armed.contender.source === 'package' || armed.contender.source === 'override')
+ && shadowed.some((contender) => contender.source === 'runtime');
+ const why = armed.contender.source === 'override'
+ ? ` because it is the declared override of '${owner}' (ADR-0146 D9)`
+ : sealedOverStored
+ ? ` because a managed package's flow is sealed (ADR-0126 §2)`
+ : armed.contender.source === 'package'
+ ? ' by package id'
+ : ' by arrival order';
const remedy = sealedOverStored
- ? 'To change what runs, clone the flow under a new name, or switch the packaged flow off.'
+ ? undeclaredRemedy(owner)
: 'Rename one of them.';
logger?.warn(
`[Automation] Flow name collision: '${name}' is claimed by ${group.length} definitions ` +
diff --git a/packages/services/service-automation/src/flow-sync-one-precedence.test.ts b/packages/services/service-automation/src/flow-sync-one-precedence.test.ts
index d769d428b90..eb883da91d5 100644
--- a/packages/services/service-automation/src/flow-sync-one-precedence.test.ts
+++ b/packages/services/service-automation/src/flow-sync-one-precedence.test.ts
@@ -21,11 +21,20 @@
// decision. The composition over two real boots is
// `flow-shipped-name-stored-row-boot.dogfood.test.ts` (packages/qa/dogfood).
-import { describe, it, expect } from 'vitest';
+import { describe, it, expect, vi } from 'vitest';
import { LiteKernel } from '@objectstack/core';
import type { Plugin, PluginContext } from '@objectstack/core';
+import type { AutomationContext } from '@objectstack/spec/contracts';
+import { bindStoredRow } from '@objectstack/metadata-protocol';
import { AutomationServicePlugin } from './plugin.js';
-import type { AutomationEngine } from './engine.js';
+import type { AutomationEngine, FlowTriggerBinding } from './engine.js';
+import { InMemoryFlowActivationStore } from './flow-activation-store.js';
+import { defineActionDescriptor } from '@objectstack/spec/automation';
+
+/** `resumeAuthority: 'any'` is required of a pausing fixture since #5561. */
+const HOLD_DESCRIPTOR = defineActionDescriptor({
+ type: 'hold', version: '1.0.0', name: 'Hold', supportsPause: true, resumeAuthority: 'any',
+});
const FLOW = 'order_sync';
const OTHER = 'lonely_flow';
@@ -207,3 +216,295 @@ describe('[#20913] the metadata:reloaded re-sync resolves through the same decis
}
});
});
+
+// ── [#28412] The declared .custom override, at every arming step ───────────
+//
+// ADR-0146 D9 item 1: the shipping package's declared override IS the
+// packaged flow. What the protocol serves for it (its view half is
+// `protocol.flow-stored-row-shipped-name.test.ts` and, over the real engine,
+// `protocol-flow-custom-override.test.ts` in packages/objectql) is armed by
+// every step: the boot pull over the registry, where the hydration recorded
+// the row's `package_id` (`bindStoredRow`), the `kernel:ready` sync, the
+// `metadata:reloaded` re-sync and the metadata-mutation sync over the view.
+// Removing it re-arms the loader's body in one registration — never a
+// withdraw-then-add, so no record change lands on an unbound name. As the
+// cases above, a view listing both contenders is used on purpose: whatever a
+// view lists, every step arms the declared override by the one decision.
+
+describe('[#28412] the declared .custom override is armed by every step, and its removal re-arms the base', () => {
+ const OWNER = 'com.example.crm';
+ const PAIR = `${OWNER}.custom`;
+ const DECLARED = { overrides: { package: OWNER, basedOn: '1.0.0' } };
+ const OBJECT = 'ticket';
+ const START = { objectName: OBJECT, triggerType: 'record-after-update' };
+
+ /** start (record trigger) → mark(tag) → end: which body ran is the tag it records. */
+ function triggered(label: string, extra: Record = {}) {
+ return {
+ name: FLOW,
+ label,
+ type: 'autolaunched',
+ nodes: [
+ { id: 'start', type: 'start', label: 'Start', config: START },
+ { id: 'mark', type: 'mark', label: 'Mark', config: { tag: label } },
+ { id: 'end', type: 'end', label: 'End' },
+ ],
+ edges: [
+ { id: 'e1', source: 'start', target: 'mark' },
+ { id: 'e2', source: 'mark', target: 'end' },
+ ],
+ ...extra,
+ };
+ }
+ const base = () => triggered('LOADER', { _packageId: OWNER, _provenance: 'package' });
+ /** The registry's hydrated copy of a stored row, its `package_id` column recorded. */
+ const hydrated = (binding: string | null, body: Record = DECLARED) =>
+ bindStoredRow(triggered('OVERRIDE', { ...body, _provenance: 'org' }), binding);
+ /** What the protocol's view serves for the declared override: in the base's slot, under its provenance. */
+ const served = () => triggered('OVERRIDE', { ...DECLARED, _packageId: OWNER, _provenance: 'package' });
+
+ interface Step {
+ registry: unknown[];
+ view: unknown[];
+ }
+
+ /**
+ * One composition: the boot pull's registry, the protocol's view, the
+ * loader's set, a mutation signal, a recording `record_change` trigger
+ * and a `mark` node executor.
+ */
+ async function compose(c: Step) {
+ const listeners: Array<(evt: unknown) => void> = [];
+ const captured: { ctx?: PluginContext } = {};
+ const bindings: FlowTriggerBinding[] = [];
+ const callbacks = new Map Promise>();
+ const ran: string[] = [];
+ const kernel = new LiteKernel({ logger: { level: 'silent' } } as never);
+ kernel.use({
+ name: 'fake-composition',
+ version: '1.0.0',
+ async init(ctx: PluginContext) {
+ captured.ctx = ctx;
+ const register = (ctx as unknown as { registerService(n: string, s: unknown): void }).registerService;
+ register('objectql', {
+ registry: { listItems: (type: string) => (type === 'flow' ? c.registry : []), getObject: () => undefined },
+ });
+ register('protocol', {
+ packagedArtifactOwner: (request: { type: string; name: string }) =>
+ request.type === 'flow' && request.name === FLOW ? OWNER : undefined,
+ async getMetaItemsForExecution(q: { type: string }) {
+ return { items: q.type === 'flow' ? c.view : [] };
+ },
+ onMetadataMutation(listener: (evt: unknown) => void) {
+ listeners.push(listener);
+ return () => undefined;
+ },
+ });
+ },
+ } as Plugin);
+ kernel.use(new AutomationServicePlugin());
+ kernel.use({
+ name: 'test.wiring',
+ version: '1.0.0',
+ async init(ctx: PluginContext) {
+ const automation = ctx.getService('automation');
+ automation.registerTrigger({
+ type: 'record_change',
+ start(binding: FlowTriggerBinding, cb: (ctx: AutomationContext) => Promise) {
+ bindings.push(binding);
+ callbacks.set(binding, cb);
+ },
+ stop(flowName: string) {
+ for (let i = bindings.length - 1; i >= 0; i--) {
+ if (bindings[i].flowName === flowName) callbacks.delete(bindings.splice(i, 1)[0]);
+ }
+ },
+ });
+ automation.registerNodeExecutor({
+ type: 'hold',
+ descriptor: HOLD_DESCRIPTOR,
+ async execute() { return { success: true, suspend: true, correlation: 'held' }; },
+ } as never);
+ automation.registerNodeExecutor({
+ type: 'mark',
+ async execute(node: { config?: { tag?: string } }) {
+ ran.push(String(node.config?.tag));
+ return { success: true };
+ },
+ } as never);
+ },
+ } as Plugin);
+ await kernel.bootstrap();
+ const engine = kernel.getService('automation');
+ const withdrawn = vi.spyOn(engine, 'withdrawFlow');
+ const armed = async () => ((await engine.getFlow(FLOW)) as { label?: string } | null)?.label;
+ /** One record change of OBJECT: every bound callback fires; returns the bodies that ran. */
+ const recordChange = async () => {
+ ran.length = 0;
+ for (const b of [...bindings].filter((x) => x.object === OBJECT)) {
+ await callbacks.get(b)!({ object: OBJECT, event: 'record-after-update', record: { id: 't1' } } as never);
+ }
+ return [...ran];
+ };
+ const settle = async () => {
+ for (let i = 0; i < 5; i++) await new Promise((r) => setTimeout(r, 0));
+ };
+ const reload = async () => {
+ await captured.ctx!.trigger('metadata:reloaded', {});
+ await settle();
+ };
+ const mutate = async () => {
+ for (const l of listeners) l({ type: 'flow', name: FLOW, state: 'active' });
+ await settle();
+ };
+ /** The tags the `mark` node recorded since the last call. */
+ const ranSince = async () => ran.splice(0, ran.length);
+ return { kernel, engine, armed, recordChange, reload, mutate, withdrawn, ranSince, bindings: () => bindings.filter((b) => b.flowName === FLOW) };
+ }
+
+ it('the boot pull arms the override over the loader\'s body, with no shadowing receipt', async () => {
+ const r = await compose({ registry: [base(), hydrated(PAIR)], view: [] });
+ try {
+ expect(await r.armed()).toBe('OVERRIDE');
+ expect(r.engine.getShadowedFlows()).toEqual([]);
+ expect(await r.recordChange()).toEqual(['OVERRIDE']);
+ } finally {
+ await r.kernel.shutdown();
+ }
+ });
+
+ it('the kernel:ready sync arms the override, whatever order the view lists the two in', async () => {
+ for (const view of [[base(), hydrated(PAIR)], [hydrated(PAIR), base()], [served()]]) {
+ const r = await compose({ registry: [], view });
+ try {
+ expect(await r.armed()).toBe('OVERRIDE');
+ expect(await r.recordChange()).toEqual(['OVERRIDE']);
+ } finally {
+ await r.kernel.shutdown();
+ }
+ }
+ });
+
+ for (const step of ['reload', 'mutate'] as const) {
+ const label = step === 'reload' ? 'the metadata:reloaded re-sync' : 'the metadata-mutation sync';
+ it(`${label}: publishing the override arms it, deleting it re-arms the base in place — one body per change`, async () => {
+ const c: Step = { registry: [base()], view: [base()] };
+ const r = await compose(c);
+ try {
+ expect(await r.armed()).toBe('LOADER');
+ expect(await r.recordChange()).toEqual(['LOADER']);
+
+ // Publish: the view now lists the declared override beside the loader's body.
+ c.view = [base(), hydrated(PAIR)];
+ await r[step]();
+ expect(await r.armed()).toBe('OVERRIDE');
+ expect(r.bindings()).toHaveLength(1);
+ expect(await r.recordChange()).toEqual(['OVERRIDE']);
+
+ // …and as the protocol serves it: one item, in the base's slot.
+ c.view = [served()];
+ await r[step]();
+ expect(await r.armed()).toBe('OVERRIDE');
+ expect(await r.recordChange()).toEqual(['OVERRIDE']);
+
+ // Delete: the view serves the loader's body again.
+ c.view = [base()];
+ await r[step]();
+ expect(await r.armed()).toBe('LOADER');
+ expect(r.bindings()).toHaveLength(1);
+ expect(await r.recordChange()).toEqual(['LOADER']);
+ // Re-armed by one registration, never withdrawn first.
+ expect(r.withdrawn).not.toHaveBeenCalled();
+ } finally {
+ await r.kernel.shutdown();
+ }
+ });
+ }
+
+ for (const [label, row] of [
+ ['a dependent package\'s declaration', () => hydrated('com.example.partner')],
+ ['a package-less row stamping the pair', () => hydrated(null, { ...DECLARED, _packageId: PAIR })],
+ ['the pair\'s undeclared row', () => hydrated(PAIR, {})],
+ ] as const) {
+ it(`${label} is armed by no step`, async () => {
+ const c: Step = { registry: [base(), row()], view: [base(), row()] };
+ const r = await compose(c);
+ try {
+ expect(await r.armed()).toBe('LOADER');
+ await r.reload();
+ expect(await r.armed()).toBe('LOADER');
+ await r.mutate();
+ expect(await r.armed()).toBe('LOADER');
+ expect(await r.recordChange()).toEqual(['LOADER']);
+ } finally {
+ await r.kernel.shutdown();
+ }
+ });
+ }
+
+ it('the ledger is the base\'s: switched off, the override is silent; switched on, it is armed again', async () => {
+ const r = await compose({ registry: [base(), hydrated(PAIR)], view: [served()] });
+ try {
+ const store = new InMemoryFlowActivationStore();
+ r.engine.setFlowActivationStore(store);
+ expect(r.engine.packagedFlowOwner(FLOW)).toBe(OWNER);
+
+ await r.engine.toggleFlow(FLOW, false);
+ expect(await store.list()).toEqual([{ name: FLOW, packageId: OWNER, active: false }]);
+ expect(r.bindings()).toHaveLength(0);
+ expect(await r.recordChange()).toEqual([]);
+ expect((await r.engine.execute(FLOW)).code).toBe('FLOW_DISABLED');
+ // A re-sync re-registers the override; the ledger keeps it silent.
+ await r.reload();
+ expect(await r.armed()).toBe('OVERRIDE');
+ expect(r.bindings()).toHaveLength(0);
+
+ await r.engine.toggleFlow(FLOW, true);
+ expect(r.bindings()).toHaveLength(1);
+ expect(await r.recordChange()).toEqual(['OVERRIDE']);
+ } finally {
+ await r.kernel.shutdown();
+ }
+ });
+ it('with N-12: a run suspended on the base finishes on the base; new runs start on the override', async () => {
+ /** start → hold (pauses) → mark(tag) → end. */
+ const pausing = (label: string, extra: Record = {}) => ({
+ name: FLOW, label, type: 'autolaunched',
+ nodes: [
+ { id: 'start', type: 'start', label: 's' },
+ { id: 'approve', type: 'hold', label: 'h' },
+ { id: 'mark', type: 'mark', label: 'm', config: { tag: label } },
+ { id: 'end', type: 'end', label: 'e' },
+ ],
+ edges: [
+ { id: 'e1', source: 'start', target: 'approve' },
+ { id: 'e2', source: 'approve', target: 'mark' },
+ { id: 'e3', source: 'mark', target: 'end' },
+ ],
+ ...extra,
+ });
+ const c: Step = { registry: [], view: [pausing('LOADER', { _packageId: OWNER, _provenance: 'package' })] };
+ const r = await compose(c);
+ try {
+ const parked = await r.engine.execute(FLOW, {} as never);
+ expect(parked.status, JSON.stringify(parked)).toBe('paused');
+
+ c.view = [pausing('OVERRIDE', { ...DECLARED, _packageId: OWNER, _provenance: 'package' })];
+ await r.mutate();
+ expect(await r.armed()).toBe('OVERRIDE');
+
+ const fresh = await r.engine.execute(FLOW, {} as never);
+ expect(fresh.status).toBe('paused');
+ const ranLog: string[] = [];
+ const resumedOld = await r.engine.resume(parked.runId!);
+ expect(resumedOld.success, JSON.stringify(resumedOld)).toBe(true);
+ ranLog.push(...(await r.ranSince()));
+ const resumedNew = await r.engine.resume(fresh.runId!);
+ expect(resumedNew.success, JSON.stringify(resumedNew)).toBe(true);
+ ranLog.push(...(await r.ranSince()));
+ expect(ranLog).toEqual(['LOADER', 'OVERRIDE']);
+ } finally {
+ await r.kernel.shutdown();
+ }
+ });
+});
diff --git a/packages/services/service-automation/src/plugin.ts b/packages/services/service-automation/src/plugin.ts
index 5b135890c1f..5f6f76251b8 100644
--- a/packages/services/service-automation/src/plugin.ts
+++ b/packages/services/service-automation/src/plugin.ts
@@ -4971,6 +4971,14 @@ export class AutomationServicePlugin implements Plugin {
* the same winners by this same decision, so its receipt describes what is
* armed after them too. The collision warning is logged by every step that
* meets a contested name.
+ *
+ * [#28412, ADR-0146 D9 item 1] One stored row of a shipped name IS armed:
+ * the shipping package's declared `.custom` override. The protocol's view
+ * serves it in the package's slot (once, in place of the loader's body),
+ * and the boot pull ranks it above the loader's body by its stored
+ * binding (`flow-precedence.ts`), so every step arms it, with no receipt.
+ * Its removal puts the loader's body back in the view, and each step
+ * re-arms that body by one `registerFlow` — never a withdraw first.
*/
private resolveFlowContenders(ctx: PluginContext, items: readonly unknown[]) {
const engine = this.engine;