From a00da81ff148ac830c19890128157d2abd97dd05 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 16:56:29 +0000 Subject: [PATCH 1/3] docs(metadata-protocol): the binding and pair-row comments follow the landed rules After #28673 withRowBinding replaces a body's _packageId by the row's column, or drops it for a package-less row, at every read including the registry hydration and the view-container expansions. The stored-row-binding header and hydrateOverlayIntoRegistry's #23233 header no longer say a body keeps its own stamp or that expansions register from the unstamped row. The deleteMetaItem receipt-intent comment no longer says the pair-row removal covers every open-override type: it is flow only. Prose only. Co-Authored-By: Claude --- packages/metadata-protocol/src/protocol.ts | 16 ++++++++++------ .../metadata-protocol/src/stored-row-binding.ts | 14 +++++++------- 2 files changed, 17 insertions(+), 13 deletions(-) diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 0d766864993..b9627e74c2b 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -18640,10 +18640,12 @@ export class ObjectStackProtocolImplementation implements * suggestion of an `isDefault` set) saw the item as unowned: on a host * kernel until the next list read, and on an environment kernel that * hydrates its own store until never. ⛔ No owner is derived: the binding is - * only read, a body that already names a `_packageId` keeps it, a row with - * no binding stays unowned, and an artifact's envelope still wins over the - * stamp ({@link mergeArtifactProtection}). The expansions of a view - * container are registered from the unstamped row, as before. + * only read, and since #28673 the column wins over the body — a body that + * names a different `_packageId` has it replaced by the row's binding, and + * a row with no binding has a body stamp dropped, so it stays unowned. An + * artifact's envelope still wins over the stamp + * ({@link mergeArtifactProtection}). The expansions of a view container are + * registered from the stamped row too ({@link hydrateExpandedViewItems}). * * ## [#9111] `type` is an ASSERTED input, not a silently-trusted one * @@ -28850,8 +28852,10 @@ 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. - // [#28412] The pair's row of an open-override type is - // removed as the declared save wrote it: runtime-only. + // [#28412] The pair's row of a type whose D9 conditions + // are delivered (`flow` only, {@link isPairRowRemoval}) + // is removed as the declared save wrote it: runtime-only. + // Every other type keeps the intent main gave it. intent: artifactBacked && !pairRowRemoval ? 'override-artifact' : 'runtime-only', diff --git a/packages/metadata-protocol/src/stored-row-binding.ts b/packages/metadata-protocol/src/stored-row-binding.ts index f745c0f7e92..641513e0823 100644 --- a/packages/metadata-protocol/src/stored-row-binding.ts +++ b/packages/metadata-protocol/src/stored-row-binding.ts @@ -5,13 +5,13 @@ * * 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). + * (`hydrateOverlayIntoRegistry`) states it as `_packageId` since #28594 and + * #28673 (`withRowBinding` replaces a body's own stamp by the column, or drops + * it for a package-less row), 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 any copy of a body made outside those readers keeps + * whatever it says. * * 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 From 7d00bb3753e5f2afd8c45d13d7274c33de9e8e56 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 17:56:44 +0000 Subject: [PATCH 2/3] fix(metadata-protocol, service-automation): a declared .custom flow override is armed from the first boot pull The registry keeps one bare slot per flow name and the boot hydration gave it to whichever stored row the store read last. With a legacy package-less row read after the shipping package's declared .custom override, the boot pull armed the base flow and a record change before kernel:ready ran it. The hydration now keeps the declared override in the slot over any other stored row of the name (isServedOverrideRowOf on the stored binding), and carries the row that loses on the override's body. The boot pull lists it, so the existing shadowing receipt and warning name it beside the override. The legacy row stays stored and unarmed. Co-Authored-By: Claude --- .../28717-flow-override-first-boot-pull.md | 17 ++ packages/metadata-protocol/src/index.ts | 1 + packages/metadata-protocol/src/protocol.ts | 16 +- .../src/stored-flow-slot.test.ts | 85 ++++++ .../metadata-protocol/src/stored-flow-slot.ts | 108 +++++++ ...erride-first-boot-pull.integration.test.ts | 275 ++++++++++++++++++ .../services/service-automation/src/plugin.ts | 10 +- 7 files changed, 509 insertions(+), 3 deletions(-) create mode 100644 .changeset/28717-flow-override-first-boot-pull.md create mode 100644 packages/metadata-protocol/src/stored-flow-slot.test.ts create mode 100644 packages/metadata-protocol/src/stored-flow-slot.ts create mode 100644 packages/services/service-automation/src/flow-declared-override-first-boot-pull.integration.test.ts diff --git a/.changeset/28717-flow-override-first-boot-pull.md b/.changeset/28717-flow-override-first-boot-pull.md new file mode 100644 index 00000000000..a58d7b56919 --- /dev/null +++ b/.changeset/28717-flow-override-first-boot-pull.md @@ -0,0 +1,17 @@ +--- +'@objectstack/metadata-protocol': patch +'@objectstack/service-automation': patch +--- + +fix(metadata-protocol, service-automation): a declared `.custom` flow override is armed from the first boot pull, and a legacy package-less row of the same name is named in the boot's shadowing warning (#28717) + +Clause-②: no +Metadata-protocol: none + +A shipped flow name can hold two stored rows at once: the shipping package's declared override in `.custom` (#28412) and a legacy package-less row saved before it. The registry keeps one bare slot per flow name, and the boot hydration used to give it to whichever row the store read last. When that was the legacy row, the automation boot pull never saw the override: it armed the base flow, and a record change fired before `kernel:ready` ran the base with no error. + +- **The override keeps the slot.** The registry hydration now keeps the declared override in the name's bare slot over any other stored row of the name, in either read order. It is judged by the one served-override predicate on the row's stored `package_id` column, never on a body's `_packageId`. Every other pair of stored rows keeps "the last row read wins". +- **The legacy row stays stored and shadowed.** It is not deleted or migrated. The boot pull lists it beside the override, so the engine's shadowing receipt records it as a runtime row, and the existing collision warning names it with the override that is armed. +- **No change when only one row exists.** A legacy row alone is still shadowed by the base and warned. An override alone is still armed silently. + +`shadowedStoredRowsOf` is newly exported from `@objectstack/metadata-protocol`. It returns the stored rows a hydrated flow body holds the bare slot over, carried as a non-enumerable symbol that never leaves the process. diff --git a/packages/metadata-protocol/src/index.ts b/packages/metadata-protocol/src/index.ts index 6f7da219c85..3ad3014bc8f 100644 --- a/packages/metadata-protocol/src/index.ts +++ b/packages/metadata-protocol/src/index.ts @@ -249,6 +249,7 @@ export type { PackagelessOverlayCensus, PackagelessOverlayMigration } from './pr // 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'; +export { shadowedStoredRowsOf } from './stored-flow-slot.js'; // [ADR-0131 D6] The `unmanaged` install mode's walled-posture refusal — the // function `importUnmanagedPackage` raises, exported so the install door diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index b9627e74c2b..e35b1e25884 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -185,6 +185,7 @@ import { deriveNamespaceFromPackageId } from '@objectstack/spec/kernel'; import { customizationPackageId } from '@objectstack/spec/kernel'; import { isServedOverrideRowOf, type ServedOverrideRow } from './served-override.js'; import { bindStoredRow } from './stored-row-binding.js'; +import { incomingTakesStoredFlowSlot } from './stored-flow-slot.js'; import { listServedOverrides, type ServedOverrideEntry } from './protocol-served-overrides.js'; // [#28413, ADR-0146 D11] The package-less overlay migration (`os migrate // packageless-overlays`), and the save's landing on a migrated row. @@ -18800,7 +18801,20 @@ export class ObjectStackProtocolImplementation implements // ({@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); + if (canonicalType === 'flow') { + bindStoredRow(registered, options.packageId ?? null); + // [#28717] …and the shipping package's declared override keeps the + // name's one bare slot over any other stored row of the name, in + // whatever order the store reads them, so the boot pull arms it + // from the first pull. The row that loses stays stored and is + // carried on the override's body for the boot pull to name + // ({@link incomingTakesStoredFlowSlot}). + const held = typeof registry.getItem === 'function' + ? registry.getItem(type, String((data as any).name)) + : undefined; + const owner = this.packagedArtifactOwner({ type, name: String((data as any).name) }); + if (!incomingTakesStoredFlowSlot(held, registered, owner)) return true; + } registry.registerItem(type, registered, 'name' as any); await this.hydrateExpandedViewItems(type, data, options); return true; diff --git a/packages/metadata-protocol/src/stored-flow-slot.test.ts b/packages/metadata-protocol/src/stored-flow-slot.test.ts new file mode 100644 index 00000000000..50c953ef23d --- /dev/null +++ b/packages/metadata-protocol/src/stored-flow-slot.test.ts @@ -0,0 +1,85 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#28717] The bare-slot order between two stored rows of one shipped flow + * name (`stored-flow-slot.ts`): the declared `.custom` override keeps the slot + * in either read order, the row that loses is carried on it once, the same row + * read again replaces itself, and every other pair keeps "last read wins". + * The composition over a real boot is + * `flow-declared-override-first-boot-pull.integration.test.ts` + * (service-automation). + */ +import { describe, expect, it } from 'vitest'; +import { bindStoredRow } from './stored-row-binding.js'; +import { incomingTakesStoredFlowSlot, shadowedStoredRowsOf } from './stored-flow-slot.js'; + +const BASE = 'com.acme.crm'; +const PAIR = `${BASE}.custom`; +const NAME = 'crm_flow'; + +const body = (label: string, extra: Record = {}) => ({ name: NAME, label, ...extra }); +const override = () => bindStoredRow(body('OVERRIDE', { overrides: { package: BASE, basedOn: '1.0.0' } }), PAIR); +const legacy = () => bindStoredRow(body('LEGACY'), null); +const labels = (rows: readonly object[]) => rows.map((r) => (r as { label?: string }).label); + +describe('[#28717] the declared override keeps the bare slot of its shipped flow name', () => { + it('held override, incoming legacy row: the override keeps the slot and carries the legacy row', () => { + const held = override(); + const incoming = legacy(); + expect(incomingTakesStoredFlowSlot(held, incoming, BASE)).toBe(false); + expect(labels(shadowedStoredRowsOf(held))).toEqual(['LEGACY']); + }); + + it('held legacy row, incoming override: the override takes the slot and carries the legacy row', () => { + const held = legacy(); + const incoming = override(); + expect(incomingTakesStoredFlowSlot(held, incoming, BASE)).toBe(true); + expect(labels(shadowedStoredRowsOf(incoming))).toEqual(['LEGACY']); + }); + + it('the legacy row read again is carried once, as its latest body', () => { + const held = override(); + incomingTakesStoredFlowSlot(held, legacy(), BASE); + const again = bindStoredRow(body('LEGACY 2'), null); + expect(incomingTakesStoredFlowSlot(held, again, BASE)).toBe(false); + expect(labels(shadowedStoredRowsOf(held))).toEqual(['LEGACY 2']); + }); + + it('the override read again replaces itself and keeps what it shadowed, declared or not', () => { + const held = override(); + incomingTakesStoredFlowSlot(held, legacy(), BASE); + const resaved = bindStoredRow(body('OVERRIDE, UNDECLARED'), PAIR); + expect(incomingTakesStoredFlowSlot(held, resaved, BASE)).toBe(true); + expect(labels(shadowedStoredRowsOf(resaved))).toEqual(['LEGACY']); + }); + + it('the binding decides, never a body stamp: a package-less row stamping the pair is not the override', () => { + const held = override(); + const stamped = bindStoredRow(body('STAMPED', { _packageId: PAIR, overrides: { package: BASE, basedOn: '1.0.0' } }), null); + expect(incomingTakesStoredFlowSlot(held, stamped, BASE)).toBe(false); + const heldStamped = bindStoredRow(body('STAMPED', { _packageId: PAIR, overrides: { package: BASE, basedOn: '1.0.0' } }), null); + expect(incomingTakesStoredFlowSlot(heldStamped, legacy(), BASE)).toBe(true); + }); + + it('control: with no declared override among them, the last row read takes the slot and nothing is carried', () => { + const held = legacy(); + const undeclaredPair = bindStoredRow(body('PAIR, UNDECLARED'), PAIR); + expect(incomingTakesStoredFlowSlot(held, undeclaredPair, BASE)).toBe(true); + expect(shadowedStoredRowsOf(undeclaredPair)).toEqual([]); + expect(incomingTakesStoredFlowSlot(undeclaredPair, legacy(), BASE)).toBe(true); + }); + + it('control: an empty slot, a slot holding no stored row, or a name no package ships, settles nothing', () => { + expect(incomingTakesStoredFlowSlot(undefined, legacy(), BASE)).toBe(true); + expect(incomingTakesStoredFlowSlot(body('LOADER', { _packageId: BASE }), legacy(), BASE)).toBe(true); + expect(incomingTakesStoredFlowSlot(override(), legacy(), undefined)).toBe(true); + }); + + it('the carried rows never leave the process', () => { + const held = override(); + incomingTakesStoredFlowSlot(held, legacy(), BASE); + expect(Object.keys(held)).not.toContain('shadowedStoredRows'); + expect(JSON.parse(JSON.stringify(held))).toEqual(body('OVERRIDE', { overrides: { package: BASE, basedOn: '1.0.0' } })); + expect(shadowedStoredRowsOf({ ...held })).toEqual([]); + }); +}); diff --git a/packages/metadata-protocol/src/stored-flow-slot.ts b/packages/metadata-protocol/src/stored-flow-slot.ts new file mode 100644 index 00000000000..af347018e57 --- /dev/null +++ b/packages/metadata-protocol/src/stored-flow-slot.ts @@ -0,0 +1,108 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * # Which stored row of a shipped flow name holds the registry's bare slot (#28717) + * + * The SchemaRegistry keeps ONE bare (package-less) slot per flow name, and + * the registry hydration (`hydrateOverlayIntoRegistry`) registers every stored + * `sys_metadata` row of a name into it. Before this rule the last row read + * won the slot. A shipped flow name can hold two stored rows at once: the + * shipping package's declared override in its `.custom` pair (ADR-0146 + * D9 item 1, #28412) and a legacy package-less row saved before the override + * existed. When the store read the legacy row last, the automation boot pull + * — which reads the registry, not the store — never saw the override, armed + * the base, and a record change fired before `kernel:ready` ran the base flow. + * + * ## The rule + * + * The declared override keeps the slot. It is told apart by the one + * served-override predicate (`isServedOverrideRowOf`) judged on the row's + * stored binding, the `package_id` column the hydration records + * (`storedRowBindingOf`), ⛔ never a body's `_packageId`. Every other pair of + * stored rows is unchanged: the last one read takes the slot. The same row + * read again (same binding) always replaces itself, so a re-save of the + * override, declared or not, is what the slot holds. + * + * ## The row that loses the slot is kept, stored and shadowed + * + * It is not deleted and not migrated: `flow` has `allowEnvironmentOverlay: + * false`, and the declared flow write door is #28412's. It stays at rest and + * is never armed. So that the boot names it, the override's registry body + * carries it under a non-enumerable symbol ({@link shadowedStoredRowsOf}), + * with its own stored binding still recorded on it. The boot pull lists it + * beside the override, and its precedence ranks it as the runtime row it is: + * shadowed, in the receipt, and named in the shadowing warning with the + * override that wins. Keyed by the shadowed row's binding, so a row read again + * is carried once. Like the binding, it never leaves the process. + */ + +import { isServedOverrideRowOf } from './served-override.js'; +import { storedRowBindingOf } from './stored-row-binding.js'; + +const SHADOWED_STORED_ROWS: unique symbol = Symbol.for('objectstack.metadata-protocol.shadowedStoredRows') as never; + +type ShadowRecord = Map; + +function shadowRecordOf(item: unknown): ShadowRecord | undefined { + if (item === null || typeof item !== 'object') return undefined; + if (!Object.prototype.hasOwnProperty.call(item, SHADOWED_STORED_ROWS)) return undefined; + return (item as { [SHADOWED_STORED_ROWS]?: ShadowRecord })[SHADOWED_STORED_ROWS]; +} + +function writeShadowRecord(item: object, record: ShadowRecord): void { + Object.defineProperty(item, SHADOWED_STORED_ROWS, { + value: record, + enumerable: false, + writable: false, + configurable: true, + }); +} + +/** Carry `from`'s shadowed rows onto `to`, and add `loser` (by its binding) when given. */ +function carryShadowed(from: unknown, to: object, loser?: object): void { + const record: ShadowRecord = new Map(shadowRecordOf(from) ?? []); + if (loser !== undefined) { + const binding = storedRowBindingOf(loser); + record.delete(binding === undefined ? null : binding); + record.set(binding === undefined ? null : binding, loser); + } + if (record.size > 0) writeShadowRecord(to, record); +} + +/** + * The stored flow rows a registry body holds the bare slot over: each one the + * hydration kept out of the slot, with its stored binding recorded on it. An + * empty list for a body that displaced none, a code artifact, or a copy. + */ +export function shadowedStoredRowsOf(item: unknown): readonly object[] { + return [...(shadowRecordOf(item)?.values() ?? [])]; +} + +/** + * Settle the bare slot of one flow name between the row it `held` and the + * stored row now `incoming`, both bound by {@link storedRowBindingOf}, for the + * name's shipping package `basePackageId` (the loader's set's owner, or none). + * Returns `true` when `incoming` takes the slot, `false` when the held + * declared override keeps it. Records the row that loses on the one that wins. + */ +export function incomingTakesStoredFlowSlot(held: unknown, incoming: unknown, basePackageId: unknown): boolean { + const heldBinding = storedRowBindingOf(held); + // An empty slot, or one holding no stored row: nothing to settle. + if (heldBinding === undefined || held === null || typeof held !== 'object') return true; + if (incoming === null || typeof incoming !== 'object') return true; + const incomingBinding = storedRowBindingOf(incoming); + // The same stored row read again replaces itself, keeping what it shadowed. + if (heldBinding === incomingBinding) { + carryShadowed(held, incoming); + return true; + } + const isOverride = (item: unknown, binding: string | null | undefined) => + isServedOverrideRowOf({ type: 'flow', package_id: binding ?? null, metadata: item }, basePackageId); + const incomingIsOverride = isOverride(incoming, incomingBinding); + if (!incomingIsOverride && isOverride(held, heldBinding)) { + carryShadowed(held, held, incoming); + return false; + } + if (incomingIsOverride) carryShadowed(held, incoming, held); + return true; +} diff --git a/packages/services/service-automation/src/flow-declared-override-first-boot-pull.integration.test.ts b/packages/services/service-automation/src/flow-declared-override-first-boot-pull.integration.test.ts new file mode 100644 index 00000000000..c894916cc16 --- /dev/null +++ b/packages/services/service-automation/src/flow-declared-override-first-boot-pull.integration.test.ts @@ -0,0 +1,275 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#28717, ADR-0146 D9 item 1] A shipped flow name that holds BOTH a legacy + * package-less stored row and the shipping package's declared `.custom` + * override arms the override from the FIRST boot pull, not only once + * `kernel:ready` re-arms from the protocol's view; the legacy row stays stored + * and unarmed, and the boot pull names it in the shadowing warning. + * + * ## What was broken + * + * The SchemaRegistry holds one bare slot per flow name, and the boot hydration + * (`loadMetaFromDb` → `hydrateOverlayIntoRegistry`) registered every stored row + * of the name into it, the last one read winning. When the legacy row was read + * after the override, the boot pull (`registry.listItems('flow')`) saw the + * loader's body and the legacy row only: it armed the loader's body, warned + * about the legacy row, and never saw the override. A record change fired + * between the boot pull and `kernel:ready` ran the base flow, with no error. + * + * ## Why this harness + * + * The real composition the card names: an ObjectKernel, ObjectQLPlugin's + * metadata protocol over driver-sql on a better-sqlite3 FILE, the shipping + * package registered through the `manifest` service, and the real automation + * plugin. A stored row is read back only by a cold boot, so the stack is + * booted twice on one file: the first boot stores the rows, the second is + * measured. The record trigger is the stand-in the sibling integration suites + * use (`@objectstack/trigger-record-change` depends on this package): it binds + * through ObjectQL's own `registerHook`, and a real insert fires it. + * + * The record change is fired from a plugin's `start()` that runs after the + * automation plugin's — after the boot pull, before `kernel:ready`. + * + * Each row order is booted: the store's read order decides which row the bare + * slot held before the fix, so the "legacy row read last" order is the one + * that was red. + */ + +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { ObjectKernel, ObjectLogger, type Plugin, type PluginContext } from '@objectstack/core'; +import { ObjectQLPlugin, type ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { defineStack } from '@objectstack/spec'; +import type { AutomationContext } from '@objectstack/spec/contracts'; +import { AutomationServicePlugin } from './plugin.js'; +import type { AutomationEngine, FlowTrigger, FlowTriggerBinding } from './engine.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; + +const BASE = 'com.acme.crm'; +const PAIR = `${BASE}.custom`; +const FLOW = 'crm_ticket_created'; +const TICKET = 'crm_ticket'; +const SYS = { isSystem: true } as const; + +/** The base flow, record-triggered on TICKET; `marker` names the body by its label and its end node. */ +const ticketFlow = (marker: string, extra: Record = {}) => ({ + name: FLOW, + label: marker, + type: 'autolaunched', + status: 'active', + nodes: [ + { + id: 'start', + type: 'start', + label: 'Start', + config: { objectName: TICKET, triggerType: 'record-after-create' }, + }, + { id: `end_${marker.toLowerCase()}`, type: 'end', label: 'End' }, + ], + edges: [{ id: 'e1', source: 'start', target: `end_${marker.toLowerCase()}` }], + ...extra, +}); + +const pluginManifest = (id: string) => ({ + id, + namespace: 'crm', + version: '1.0.0', + type: 'plugin', + name: 'crm', +}); + +/** The shipping package: one object and the base flow over it. */ +const shippingStack = () => + defineStack( + { + manifest: pluginManifest(BASE), + objects: [{ name: TICKET, label: 'Ticket', fields: { title: { name: 'title', label: 'Title', type: 'text' } } }], + flows: [ticketFlow('BASE')], + } as never, + { strict: false }, + ) as Record; + +type Row = 'override' | 'legacy'; + +const storedRow = (row: Row) => + row === 'override' + ? { + id: 'meta_override', + name: FLOW, + type: 'flow', + state: 'active', + package_id: PAIR, + metadata: JSON.stringify(ticketFlow('OVERRIDE', { overrides: { package: BASE, basedOn: '1.0.0' } })), + } + : { + id: 'meta_legacy', + name: FLOW, + type: 'flow', + state: 'active', + package_id: null, + metadata: JSON.stringify(ticketFlow('LEGACY')), + }; + +/** What each record change saw armed, and which body's end node its run reached. */ +interface Fired { + armedLabel: string | undefined; +} + +function objectqlRecordTrigger(ql: () => ObjectQL, automation: () => AutomationEngine, fired: Fired[]): FlowTrigger { + const packageOf = (flowName: string) => `qa-28717-trigger:${flowName}`; + return { + type: 'record_change', + start(binding: FlowTriggerBinding, callback: (ctx: AutomationContext) => Promise) { + ql().unregisterHooksByPackage(packageOf(binding.flowName)); + ql().registerHook( + 'afterInsert', + async (hook: { result?: unknown }) => { + const armed = (await automation().getFlow(binding.flowName)) as { label?: string } | null; + fired.push({ armedLabel: armed?.label }); + await callback({ record: hook.result, object: binding.object, event: 'afterInsert' } as never); + }, + { object: binding.object, packageId: packageOf(binding.flowName) }, + ); + }, + stop(flowName: string) { + ql().unregisterHooksByPackage(packageOf(flowName)); + }, + }; +} + +const kernels: ObjectKernel[] = []; +const dirs: string[] = []; +afterEach(async () => { + vi.restoreAllMocks(); + for (const k of kernels.splice(0)) await k.shutdown().catch(() => {}); + for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true }); +}); + +/** + * Boot the composition on `file`. `start` runs in a plugin that starts after + * the automation plugin: after the boot pull, before `kernel:ready`. + */ +async function boot(file: string, start: (ql: ObjectQL, automation: AutomationEngine) => Promise) { + const kernel = new ObjectKernel({ logger: { level: 'fatal' }, gracefulShutdown: false } as never); + kernels.push(kernel); + const driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: file }, useNullAsDefault: true }); + const fired: Fired[] = []; + await kernel.use({ + name: 'test.driver.sqlite', + type: 'standard', + version: '1.0.0', + async init(ctx: PluginContext) { + ctx.registerService('driver.default', driver); + }, + } as Plugin); + await kernel.use(new ObjectQLPlugin()); + await kernel.use(new AutomationServicePlugin({ suspendedRunStore: 'memory' })); + await kernel.use({ + name: 'test.shipping-package', + type: 'standard', + version: '1.0.0', + dependencies: ['com.objectstack.engine.objectql', 'com.objectstack.service-automation'], + async init(ctx: PluginContext) { + const stack = shippingStack(); + await ctx.getService<{ register(m: unknown): Promise }>('manifest') + .register({ ...(stack.manifest as object), ...stack }); + const ql = () => ctx.getService('objectql'); + const automation = () => ctx.getService('automation'); + automation().registerTrigger(objectqlRecordTrigger(ql, automation, fired)); + }, + async start(ctx: PluginContext) { + const ql = ctx.getService('objectql'); + await installDefaultOrganization(ql as never); + await start(ql, ctx.getService('automation')); + }, + } as Plugin); + await kernel.bootstrap(); + return { kernel, fired, automation: kernel.getService('automation') }; +} + +/** The first boot stores `rows`, in this order; the second, cold, is measured. */ +async function measuredBoot(rows: readonly Row[], start: (ql: ObjectQL, automation: AutomationEngine) => Promise) { + const dir = mkdtempSync(join(tmpdir(), 'qa-28717-')); + dirs.push(dir); + const file = join(dir, 'store.db'); + const first = await boot(file, async (ql) => { + for (const row of rows) await ql.insert('sys_metadata', storedRow(row), { context: SYS } as never); + }); + await first.kernel.shutdown(); + kernels.splice(kernels.indexOf(first.kernel), 1); + const warn = vi.spyOn(ObjectLogger.prototype, 'warn'); + const measured = await boot(file, start); + const collisions = warn.mock.calls + .map(([message]) => String(message)) + .filter((message) => message.includes('Flow name collision') && message.includes(`'${FLOW}'`)); + return { ...measured, collisions }; +} + +const insertTicket = (ql: ObjectQL, title: string) => ql.insert(TICKET, { title }, { context: SYS } as never); + +describe('[#28717] a declared .custom flow override is armed from the first boot pull', () => { + for (const rows of [['override', 'legacy'], ['legacy', 'override']] as const) { + describe(`rows stored ${rows.join(' then ')}`, () => { + it('a record change fired before kernel:ready runs the override\'s body', async () => { + let armedAtPull: string | undefined; + const { fired, automation } = await measuredBoot(rows, async (ql, engine) => { + armedAtPull = ((await engine.getFlow(FLOW)) as { label?: string } | null)?.label; + await insertTicket(ql, 'before kernel:ready'); + }); + expect(automation.packagedFlowOwner(FLOW), 'control: the loader\'s set holds the name').toBe(BASE); + expect(armedAtPull).toBe('OVERRIDE'); + expect(fired.map((f) => f.armedLabel)).toEqual(['OVERRIDE']); + // After kernel:ready too, and no step armed the legacy body. + expect(((await automation.getFlow(FLOW)) as { label?: string } | null)?.label).toBe('OVERRIDE'); + expect(automation.getFlowVersionHistory(FLOW).map((h) => (h.definition as { label?: string }).label)) + .not.toContain('LEGACY'); + }); + + it('the boot pull names the shadowed legacy row and the override that wins', async () => { + const { automation, collisions } = await measuredBoot(rows, async () => {}); + expect(automation.getShadowedFlows()).toEqual([ + { + name: FLOW, + armed: { source: 'override', packageId: PAIR }, + shadowed: expect.arrayContaining([{ source: 'package', packageId: BASE }, { source: 'runtime' }]), + }, + ]); + expect(automation.getShadowedFlows()[0].shadowed).toHaveLength(2); + expect(collisions).toHaveLength(1); + expect(collisions[0]).toContain(`arming the declared override in '${PAIR}' (sys_metadata)`); + expect(collisions[0]).toContain('a runtime-authored row (sys_metadata)'); + }); + }); + } + + it('control: with only the legacy row, the base is armed and the row is shadowed and warned, as before', async () => { + let armedAtPull: string | undefined; + const { fired, automation, collisions } = await measuredBoot(['legacy'], async (ql, engine) => { + armedAtPull = ((await engine.getFlow(FLOW)) as { label?: string } | null)?.label; + await insertTicket(ql, 'legacy only'); + }); + expect(armedAtPull).toBe('BASE'); + expect(fired.map((f) => f.armedLabel)).toEqual(['BASE']); + expect(automation.getShadowedFlows()).toEqual([ + { name: FLOW, armed: { source: 'package', packageId: BASE }, shadowed: [{ source: 'runtime' }] }, + ]); + expect(collisions).toHaveLength(1); + expect(((await automation.getFlow(FLOW)) as { label?: string } | null)?.label).toBe('BASE'); + }); + + it('control: with only the override, it is armed from the boot pull with no receipt and no warning (#28412)', async () => { + let armedAtPull: string | undefined; + const { fired, automation, collisions } = await measuredBoot(['override'], async (ql, engine) => { + armedAtPull = ((await engine.getFlow(FLOW)) as { label?: string } | null)?.label; + await insertTicket(ql, 'override only'); + }); + expect(armedAtPull).toBe('OVERRIDE'); + expect(fired.map((f) => f.armedLabel)).toEqual(['OVERRIDE']); + expect(automation.getShadowedFlows()).toEqual([]); + expect(collisions).toEqual([]); + }); +}); diff --git a/packages/services/service-automation/src/plugin.ts b/packages/services/service-automation/src/plugin.ts index d7b3f7ef358..7954cb78aa5 100644 --- a/packages/services/service-automation/src/plugin.ts +++ b/packages/services/service-automation/src/plugin.ts @@ -18,7 +18,7 @@ import { isConnectorCredentialRejected, isConnectorUpstreamChanged, isConnectorU import { stripReadDecorations } from '@objectstack/spec/kernel'; import { postureEnforcesWall, type TenancyPosture } from '@objectstack/spec/security'; import type { MetadataMutationEvent, SysMetadataEngine } from '@objectstack/metadata-protocol'; -import { SysMetadataRepository } from '@objectstack/metadata-protocol'; +import { SysMetadataRepository, shadowedStoredRowsOf } from '@objectstack/metadata-protocol'; import { AutomationEngine } from './engine.js'; import type { AutomationEngineOptions, FlowHistorySource, RefusalI18nService, RunSummaryLogLevel } from './engine.js'; import { describeThrownForLog, thrownMessageText } from './thrown-cause-diagnostics.js'; @@ -2108,7 +2108,13 @@ export class AutomationServicePlugin implements Plugin { } else if (typeof ql.registry.listItems !== 'function') { ctx.logger.debug('[Automation] objectql.registry.listItems is not a function'); } - const flows = ql?.registry?.listItems?.('flow') ?? []; + // [#28717] …plus the stored rows a declared override holds the + // bare slot over: the hydration keeps the override in the slot + // and carries each displaced row on its body + // (`shadowedStoredRowsOf`), so the precedence below shadows it + // and the warning names it beside the override that wins. + const flows = (ql?.registry?.listItems?.('flow') ?? []) + .flatMap((item) => [item, ...shadowedStoredRowsOf(item)]); ctx.logger.debug(`[Automation] flow pull: registry returned ${flows.length} flow(s)`); // [#11997] Collapse same-named contenders BEFORE anything is armed. // `listItems` returns a packaged flow and a same-named stored row From dc9f723595e4d1122bf5e907ecad039a307c2e8b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 19:02:29 +0000 Subject: [PATCH 3/3] =?UTF-8?q?chore(changeset):=20#28717=20declares=20Cla?= =?UTF-8?q?use-=E2=91=A1=20yes=20(widening)=20for=20the=20new=20shadowedSt?= =?UTF-8?q?oredRowsOf=20export?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude --- .changeset/28717-flow-override-first-boot-pull.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/28717-flow-override-first-boot-pull.md b/.changeset/28717-flow-override-first-boot-pull.md index a58d7b56919..bdcbff8ee03 100644 --- a/.changeset/28717-flow-override-first-boot-pull.md +++ b/.changeset/28717-flow-override-first-boot-pull.md @@ -5,7 +5,7 @@ fix(metadata-protocol, service-automation): a declared `.custom` flow override is armed from the first boot pull, and a legacy package-less row of the same name is named in the boot's shadowing warning (#28717) -Clause-②: no +Clause-②: yes (widening: the new public export shadowedStoredRowsOf from @objectstack/metadata-protocol; no accept-set change) Metadata-protocol: none A shipped flow name can hold two stored rows at once: the shipping package's declared override in `.custom` (#28412) and a legacy package-less row saved before it. The registry keeps one bare slot per flow name, and the boot hydration used to give it to whichever row the store read last. When that was the legacy row, the automation boot pull never saw the override: it armed the base flow, and a record change fired before `kernel:ready` ran the base with no error.