Skip to content

Commit c5bcd4b

Browse files
committed
feat(service-automation, metadata-protocol): a declared .custom flow override is the packaged flow
ADR-0146 D9 item 1 for flow (#28412). A flow saved into the shipping package's paired <base>.custom under the base flow's name, declaring overrides: { package: <base>, basedOn }, is now: - admitted by the D2 override write gate (flow is open; action still waits), at the draft save, the publish, os validate and install; - served by every read in the base's slot, under the base's provenance, judged by isServedOverrideRowOf on the stored package_id; - armed by the boot pull, kernel:ready, metadata:reloaded and the mutation sync in place of the base (new `override` precedence rank, read off the stored binding the registry hydration records with a non-enumerable symbol), keeping the base's ledger identity; - removable: a delete naming the pair is admitted, and every step re-arms the base with one registration. Every other stored row of a shipped flow name stays shadowed; the remedy now names the .custom declaration or a new name, not a clone. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 0b6a52e commit c5bcd4b

22 files changed

Lines changed: 1340 additions & 81 deletions
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
'@objectstack/metadata-protocol': minor
3+
'@objectstack/service-automation': minor
4+
---
5+
6+
feat(service-automation, metadata-protocol): a flow override declared in the shipping package's paired `<base>.custom` is the packaged flow — saved, served and armed in place of the base, and deleting it restores the base (#28412)
7+
8+
Clause-②: yes (widening)
9+
Metadata-protocol: semantic
10+
11+
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:
12+
13+
- **The save door admits it.** A flow saved into `<base>.custom` under the base flow's name, declaring `overrides: { package: '<base>', 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.
14+
- **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`.
15+
- **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.
16+
- **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.
17+
- **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.
18+
- **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 '<base>.custom' (overrides: { package, basedOn }), or save it under a new name". It no longer says "clone".
19+
- **The sealed save sentence for a packaged flow** names the declaration in place of the clone route: "… Declare an override from '<base>.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.
20+
21+
No spec key is added or reshaped. A flow item's existing `overrides` declaration, refused before, now takes effect.

‎packages/cli/src/utils/override-declarations.test.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,12 +98,15 @@ describe('#28410 — findOverrideDeclarationRefusals walks the artifact under th
9898
manifest: manifest(PAIR, [BASE]),
9999
dashboards: [{ name: 'crm_board', overrides: { package: BASE, basedOn: '1.0.0' } }],
100100
objects: [{ name: 'crm_account', overrides: { package: BASE, basedOn: '1.0.0' } }],
101+
// [#28412, ADR-0146 D9] A declared flow override is admitted now (its D9
102+
// conditions are delivered); an `action` declaration still waits.
101103
flows: [{ name: 'crm_flow', overrides: { package: BASE, basedOn: '1.0.0' } }],
104+
actions: [{ name: 'crm_call', objectName: 'crm_account', label: 'Call', overrides: { package: BASE, basedOn: '1.0.0' } }],
102105
pages: [{ name: 'crm_home', overrides: { package: 'com.unlisted', basedOn: '1.0.0' } }],
103106
});
104107
expect(rows.map((r) => [r.path, r.rule])).toEqual([
105108
['objects[0]', 'declared_object'],
106-
['flows[0]', 'declared_type_not_yet_available'],
109+
['actions[0]', 'declared_type_not_yet_available'],
107110
['pages[0]', 'declared_outside_pair'],
108111
]);
109112
});

‎packages/metadata-protocol/src/index.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,12 @@ export { isServedOverrideRowOf } from './served-override.js';
239239
export type { ServedOverrideRow } from './served-override.js';
240240
export type { ServedOverrideEntry } from './protocol-served-overrides.js';
241241

242+
// [#28412, ADR-0146 D9 item 1] The `package_id` column of the stored row a
243+
// registry body was hydrated from, recorded by the protocol's hydration and
244+
// read by the automation boot pull, which judges a flow override on the
245+
// column (`isServedOverrideRowOf`), ⛔ never on the body's `_packageId`.
246+
export { bindStoredRow, storedRowBindingOf } from './stored-row-binding.js';
247+
242248
// [ADR-0131 D6] The `unmanaged` install mode's walled-posture refusal — the
243249
// function `importUnmanagedPackage` raises, exported so the install door
244250
// (`@objectstack/cloud-connection` install-local) answers it with the same

‎packages/metadata-protocol/src/packaged-base-regime.ts‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -338,6 +338,29 @@ export function packagedBaseRegimeSentence(
338338
return lock + rowPrescription(row, name);
339339
}
340340

341+
/**
342+
* [#28412, ADR-0146 D2 / D9 item 1] The sealed SAVE sentence of a Regime C
343+
* item whose type the declared override opens (`flow`), or `undefined` for a
344+
* type with no Regime C row. The lock of {@link packagedBaseRegimeSentence},
345+
* then `declare` — the writer's own fix, the declaration from the base's
346+
* paired `.custom` or a rename, phrased by the override write gate's module —
347+
* in place of the row's clone path, then the row's switch, then the ADR-0126
348+
* citation. The clone is not named: a clone is a second flow beside the base,
349+
* and both run; the declared override is the one body that replaces it
350+
* (ADR-0146 D9 item 1). The removal sentence keeps the row's prescription.
351+
*
352+
* Kept under the REST door's 500-character bound for an 88-character name
353+
* (pinned beside the other rows).
354+
*/
355+
export function regimeCDeclaredSaveSentence(type: string, name: string, declare: string): string | undefined {
356+
const singular = PLURAL_TO_SINGULAR[type] ?? type;
357+
const row = packagedBaseRegimeRow(singular);
358+
if (row?.regime !== 'C') return undefined;
359+
const switchOff = row.routes.switchOff ? `, or switch it off (${row.routes.switchOff})` : '';
360+
return `Metadata item '${singular}/${name}' is provided by a managed package and is sealed against in-place edits. `
361+
+ `${declare}${switchOff}. See ${REGIME_C_ADR}.`;
362+
}
363+
341364
/** [ADR-0131 D6] The decision record every sealed-item sentence without a regime row cites. */
342365
const MANAGED_SEAL_ADR = 'docs/adr/0131-total-organization-ownership-no-null-organization-id.md';
343366

‎packages/metadata-protocol/src/protocol-lock-state.ts‎

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,11 @@
1515
* re-indented one level and otherwise unedited: the same reads, in the same
1616
* order, throwing exactly where they did with the same message.
1717
*
18+
* Edited since: [#28412, ADR-0146 D9 item 1] `declinesStoredRow` takes the
19+
* stored row it judges, and does not decline a shipped flow name's row that
20+
* the one served-override predicate (`isServedOverrideRowOf`) admits for the
21+
* shipping package — its declared `.custom` override, the packaged flow.
22+
*
1823
* ## How the moved bodies reach the class
1924
*
2025
* The shape #23050 set for `rest-server.ts` (its `data-action-routes.ts`
@@ -44,6 +49,7 @@ import type { ObjectStackProtocolImplementation } from './protocol.js';
4449
import { canonicalMetaType } from './protocol.js';
4550
import type { ItemLock } from './item-lock.js';
4651
import { isOriginGatedType } from './packaged-base-regime.js';
52+
import { isServedOverrideRowOf, type ServedOverrideRow } from './served-override.js';
4753
import { isCodeArtifactBody } from '@objectstack/metadata-core';
4854
import { PLURAL_TO_SINGULAR } from '@objectstack/spec/shared';
4955
import {
@@ -131,9 +137,15 @@ export function isStoredFlowEntryOfShippedName(
131137

132138
/** The body of `ObjectStackProtocolImplementation#declinesStoredRow`; the method's docblock there is its contract. */
133139
export function declinesStoredRow(
134-
this: Pick<LockStateHost, 'isDeclaredCodeDatasource' | 'isShippedFlowName' | 'isUnservedSealedOverlay'>,
135-
type: string, name: unknown): boolean {
136-
if (this.isShippedFlowName(type, name)) return true;
140+
this: Pick<LockStateHost, 'isDeclaredCodeDatasource' | 'isShippedFlowName' | 'isUnservedSealedOverlay' | 'packagedArtifactOwner'>,
141+
type: string, name: unknown, row?: ServedOverrideRow): boolean {
142+
if (this.isShippedFlowName(type, name)) {
143+
// [#28412, ADR-0146 D9 item 1] …except the one row that IS the
144+
// packaged flow now: the shipping package's declared override, judged
145+
// on the row's own `package_id` and body by the one served-override
146+
// predicate. Every other stored row of the name stays declined.
147+
return !isServedOverrideRowOf(row, this.packagedArtifactOwner({ type: 'flow', name: name as string }));
148+
}
137149
if (typeof name !== 'string' || name === '') return false;
138150
return this.isDeclaredCodeDatasource(type, name) || this.isUnservedSealedOverlay(type, name);
139151
}

‎packages/metadata-protocol/src/protocol-packaged-base-refusal.ts‎

Lines changed: 79 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@
3636
*/
3737

3838
import type { ObjectStackProtocolImplementation } from './protocol.js';
39-
import { isOriginGatedType, managedItemSealedSentence } from './packaged-base-regime.js';
39+
import { isOriginGatedType, managedItemSealedSentence, regimeCDeclaredSaveSentence } from './packaged-base-regime.js';
4040
import { SysMetadataRepository } from './sys-metadata-repository.js';
4141
import {
4242
customizationBasePackageId,
@@ -144,9 +144,14 @@ export function refusePackagedBaseOverride(
144144
// of the bare seal: the override write gate ({@link judgeOverrideWrite})
145145
// admits exactly the pair's declaration, so the seal is no longer the
146146
// whole story.
147+
// [#28412] A Regime C type the declared override opens (`flow`) names
148+
// the declaration in place of its clone path, keeping its switch.
149+
const owner = this.packagedArtifactOwner(request);
150+
const declare = sealedDeclareOrRenameClause(request.type, owner, request.packageId);
147151
const err = new Error(
148-
managedItemSealedSentence(request.type, request.name, 'save')
149-
+ sealedDeclareOrRenameTail(request.type, this.packagedArtifactOwner(request), request.packageId),
152+
(declare === undefined ? undefined : regimeCDeclaredSaveSentence(request.type, request.name, declare))
153+
?? managedItemSealedSentence(request.type, request.name, 'save')
154+
+ sealedDeclareOrRenameTail(request.type, owner, request.packageId),
150155
);
151156
(err as any).code = 'NOT_OVERRIDABLE';
152157
(err as any).status = 403;
@@ -164,9 +169,10 @@ export function refusePackagedBaseOverride(
164169
* (ruling A on #28390), so a dependent's one fix is a new name, and the
165170
* declare advice would send it to a refusal (`declared_outside_pair`).
166171
*
167-
* Empty for a type with no open channel — `flow` and `action` wait for
168-
* ADR-0146 D9's conditions (their regime rows keep their own routes), and an
169-
* excluded type has none — and when the shipping package has no pair id.
172+
* Empty for a type with no open channel — `action` waits for ADR-0146 D9's
173+
* conditions (its regime row keeps its own routes), and an excluded type has
174+
* none — and when the shipping package has no pair id. [#28412] `flow` is
175+
* open: its sealed sentence names the declaration too.
170176
*/
171177
function sealedDeclareOrRenameTail(type: string, basePackageId: unknown, writerPackageId: unknown): string {
172178
const singular = PLURAL_TO_SINGULAR[type] ?? type;
@@ -181,21 +187,79 @@ function sealedDeclareOrRenameTail(type: string, basePackageId: unknown, writerP
181187
+ `(overrides: { package: '${basePackageId}', basedOn }), or rename the item.`;
182188
}
183189

190+
/**
191+
* [#28412, ADR-0146 D2] {@link sealedDeclareOrRenameTail}'s choice, as the
192+
* clause a Regime C sentence carries in place of its clone path
193+
* (`regimeCDeclaredSaveSentence`): the pair (or a write naming no package)
194+
* may declare from the pair or rename; any other named package may only
195+
* rename. `undefined` exactly where the tail is empty.
196+
*/
197+
function sealedDeclareOrRenameClause(type: string, basePackageId: unknown, writerPackageId: unknown): string | undefined {
198+
const singular = PLURAL_TO_SINGULAR[type] ?? type;
199+
if (!isOpenOverrideType(singular) || typeof basePackageId !== 'string') return undefined;
200+
const pair = customizationPackageId(basePackageId);
201+
if (!pair.ok) return undefined;
202+
const writer = typeof writerPackageId === 'string' && writerPackageId.length > 0 ? writerPackageId : undefined;
203+
if (writer !== undefined && writer !== pair.value) {
204+
return `Rename it to customize it (only '${pair.value}' may declare an override of '${basePackageId}')`;
205+
}
206+
return `Declare an override from '${pair.value}' or rename it to customize it`;
207+
}
208+
184209
// ─────────────────────────────────────────────────────────────────────────
185210
// ADR-0146 D2 — the override write gate (ruling A on decision card #28390)
186211
// ─────────────────────────────────────────────────────────────────────────
187212

213+
/**
214+
* [#28412, ADR-0146 D9] The behavioural types whose D9 conditions this runtime
215+
* delivers, so the gate admits their declared override:
216+
*
217+
* - `flow` — all four. Served over the code package and rebound in one reload
218+
* (`@objectstack/service-automation`'s flow precedence and the flow view of
219+
* this package, which admit exactly the `.custom` row
220+
* `isServedOverrideRowOf` names); ledger identity inherited (the
221+
* override keeps the base's name, so `packagedFlowOwner`, the toggle door,
222+
* the §7.3 subflow guard and the `FLOW_DISABLED` consult key it as the
223+
* base); version pinning (#28405: a suspended run finishes on the body it
224+
* started on).
225+
*
226+
* ⛔ Not `action`: its override door is not cut (D9's `action` paragraph), so
227+
* its declaration keeps the "not yet available" refusal. A type joins this set
228+
* in the change that delivers its conditions, never ahead of it.
229+
*/
230+
const CONDITIONS_DELIVERED: ReadonlySet<string> = new Set(['flow']);
231+
188232
/**
189233
* Whether a declared override of `type` is admitted today: the closed list's
190234
* disposition is `overridable` AND the type waits for no ADR-0146 D9
191-
* condition. `flow` (D9 conditions, opened by its own card in this same gate)
192-
* and `action` (its ledger switch first; refused for the whole epic) carry
193-
* `conditions`, so they are classified overridable and still refused here.
194-
* Read from `OVERRIDE_TYPE_CLASSIFICATION`, never re-derived.
235+
* condition this runtime has not delivered ({@link CONDITIONS_DELIVERED}).
236+
* `flow` carries `conditions` and is delivered; `action` (its door is not cut)
237+
* carries them and is not, so it is classified overridable and still refused
238+
* here. The disposition and the conditions are read from
239+
* `OVERRIDE_TYPE_CLASSIFICATION`, never re-derived.
195240
*/
196241
function isOpenOverrideType(singular: string): boolean {
197242
const row = getOverrideTypeClassification(singular);
198-
return row?.disposition === 'overridable' && (row.conditions?.length ?? 0) === 0;
243+
if (row?.disposition !== 'overridable') return false;
244+
return (row.conditions?.length ?? 0) === 0 || CONDITIONS_DELIVERED.has(singular);
245+
}
246+
247+
/**
248+
* [#28412, ADR-0146 D9 item 1] Whether a removal addressed to `packageId`
249+
* removes the shipping package's PAIR row of an open-override type — the
250+
* row the pair's declared override is stored in. Such a removal is admitted
251+
* past the sealed-removal refusal: it removes no managed definition, and the
252+
* code package's item is what every read serves (and, for a flow, what the
253+
* automation engine arms) once the row is gone — the narrowing direction the
254+
* #6960 ruling names. ⛔ Only the pair, named: a removal naming no package, or
255+
* any other package, keeps the sealed refusal it had.
256+
*/
257+
export function isPairRowRemoval(type: string, basePackageId: unknown, packageId: unknown): boolean {
258+
const singular = PLURAL_TO_SINGULAR[type] ?? type;
259+
if (!isOpenOverrideType(singular) || typeof basePackageId !== 'string') return false;
260+
if (typeof packageId !== 'string' || packageId.length === 0) return false;
261+
const pair = customizationPackageId(basePackageId);
262+
return pair.ok && pair.value === packageId;
199263
}
200264

201265
/** One item written into one package — what the override write gate judges. */
@@ -312,9 +376,10 @@ export function judgeOverrideWrite(
312376
`${item} carries \`overrides\`, but a ${type} takes no override (ADR-0146 D6). `
313377
+ `Remove \`overrides\` and create your own ${type} under a new name ("new similar").`);
314378
}
315-
// `flow` and `action` are classified overridable and wait for D9's
316-
// conditions (`isOpenOverrideType`): "flow customization is not yet
317-
// available", and the same sentence for an action.
379+
// `action` is classified overridable and waits for D9's conditions
380+
// (`isOpenOverrideType`): "action customization is not yet available".
381+
// [#28412] `flow`'s conditions are delivered, so it is judged below
382+
// like every open type.
318383
if (!isOpenOverrideType(type)) {
319384
return refuse('declared_type_not_yet_available',
320385
`${item} carries \`overrides\`: ${type} customization is not yet available, so a declared ${type} `

‎packages/metadata-protocol/src/protocol-served-overrides.ts‎

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,12 @@
3131
* leaves a legacy organization's out, ADR-0131 D6/D7). No per-organization
3232
* axis exists to filter on (ADR-0131 D6), so every caller gets one answer.
3333
* - A row the reads decline ({@link ObjectStackProtocolImplementation.declinesStoredRow}:
34-
* a stored row under a shipped flow name, a code-defined datasource name,
35-
* sealed managed content) is not listed. Its declaration may be well
36-
* formed, but the base's document is what every read serves for the name
37-
* (ADR-0146 D9's conditions are N-11's), so it overrides nothing yet.
34+
* a code-defined datasource name, sealed managed content) is not listed.
35+
* Its declaration may be well formed, but the base's document is what
36+
* every read serves for the name, so it overrides nothing. [#28412] A
37+
* shipped flow name's declared override is not declined (ADR-0146 D9
38+
* item 1): the reads serve it over the base's flow, so it is listed; the
39+
* row is handed over whole, so the decline judges it on its own binding.
3840
*
3941
* ## What it reports, and what it never computes
4042
*
@@ -112,7 +114,7 @@ export async function listServedOverrides(
112114
if (typeof row?.name !== 'string' || row.name === '') continue;
113115
if (!isServedOverrideRowOf(row, base)) continue;
114116
const type = PLURAL_TO_SINGULAR[row.type] ?? row.type;
115-
if (this.declinesStoredRow(type, row.name)) continue;
117+
if (this.declinesStoredRow(type, row.name, row)) continue;
116118
const member = typeof row.bundle_member === 'string' && row.bundle_member !== '' ? row.bundle_member : undefined;
117119
// `isServedOverrideRowOf` parsed this body already; a body it admitted parses.
118120
const declaration = (storedRowDocument(row) as { overrides?: { basedOn?: unknown } }).overrides;

0 commit comments

Comments
 (0)