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;