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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .changeset/28717-flow-override-first-boot-pull.md
Original file line number Diff line number Diff line change
@@ -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-②: 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 `<base>.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.
1 change: 1 addition & 0 deletions packages/metadata-protocol/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
32 changes: 25 additions & 7 deletions packages/metadata-protocol/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -18640,10 +18641,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
*
Expand Down Expand Up @@ -18798,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;
Expand Down Expand Up @@ -28850,8 +28866,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',
Expand Down
85 changes: 85 additions & 0 deletions packages/metadata-protocol/src/stored-flow-slot.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = {}) => ({ 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([]);
});
});
108 changes: 108 additions & 0 deletions packages/metadata-protocol/src/stored-flow-slot.ts
Original file line number Diff line number Diff line change
@@ -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 `<base>.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<string | null, object>;

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;
}
14 changes: 7 additions & 7 deletions packages/metadata-protocol/src/stored-row-binding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<base>.custom` — by the
Expand Down
Loading
Loading