Skip to content

Commit 3ee6af4

Browse files
feat(service-automation, metadata-protocol): a flow override declared in BASE.custom is the packaged flow, saved, served and armed in place of the base (#28412) (#28693)
domain:server#1 · os-project-manager · session_01R1q2xdtYgU6FKrZ7LgjPCC Fixes #28412 Clause-②: yes (widening: a flow saved into the shipping package's paired .custom under the base flow's name, declaring overrides, is admitted at the save door, the publish, os validate and install where it was refused with declared_type_not_yet_available, and a /meta delete naming that pair removes its row; nothing that was admitted is refused) Metadata-protocol: semantic ## 维护者速读 - **改了什么**:基础包随附的流程,现在可以在它的配对包 `BASE.custom` 里用同名并声明 `overrides: { package: BASE, basedOn }` 来覆盖。保存、发布、`os validate`、安装都放行;所有读取都在基础包的位置上返回这份覆盖;自动化引擎在启动拉取、`kernel:ready`、`metadata:reloaded`、元数据变更同步这四步都用覆盖替换基础流程。删除覆盖后,基础流程原地重新挂上。 - **为什么改**:ADR-0146 D9 第 1 项(#28236 的 N-11)。以前只能克隆成新名字,结果克隆和原流程在每次记录变更时都会跑。 - **风险与回滚**:中。只有配对包、声明了 `overrides`、并且由存储的 `package_id` 列绑定的那一行才算覆盖,绝不看正文里的 `_packageId`。依赖包的声明、正文冒充配对包的行、配对包未声明的同名行,照旧被遮蔽并告警。`action` 仍是「暂不可用」。不改 spec、不改 ADR。回滚即还原本 PR。 - **先看**:`packages/services/service-automation/src/flow-precedence.ts`(新增 `override` 优先级,只认存储绑定);`packages/metadata-protocol/src/protocol-lock-state.ts` 的 `declinesStoredRow`(用 N-10 的 `isServedOverrideRowOf` 放行这一行);`packages/metadata-protocol/src/stored-row-binding.ts`(把 `package_id` 列带到注册表正文上的不可枚举 symbol)。 - **两分钟验证**:`pnpm --filter @objectstack/service-automation exec vitest run src/flow-sync-one-precedence.test.ts`,预期 16/16 通过(四个挂载步骤、删除复原、开关、N-12 挂起运行)。 - **需要你做的**:无。席位安排契约评审后落地。 ## What changes **The save door (Wanted 5).** `isOpenOverrideType` in `protocol-packaged-base-refusal.ts` admits `flow` through a `CONDITIONS_DELIVERED` set (`flow` only), so N-09's `judgeOverrideWrite` admits the pair's declared flow. There is no second gate. `action` keeps "action customization is not yet available". `tenantAuthoredWriteRefusal` no longer applies the packaged-base lock to a write the gate admits. **The remedy (Wanted 6).** The flow's sealed save sentence 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 (...)". A partner package gets "Rename it to customize it (only 'BASE.custom' may declare an override of 'BASE')". This comes from the new `regimeCDeclaredSaveSentence` in `packaged-base-regime.ts`, which keeps the switch route and the ADR-0126 citation, and stays under the REST door's 500-character cap. A delete still names the clone and the switch. The boot pull's collision warning now says "declare it as an override in 'BASE.custom' (overrides: { package: 'BASE', basedOn }), or save it under a new name". It no longer says "clone". **The flow view (Wanted 2).** `declinesStoredRow` takes the stored row it judges. For a shipped flow name it declines every row except the one `isServedOverrideRowOf(row, owner)` admits, judged on the `package_id` column. The list, the execution view, the by-name read, the layered read and `listServedOverrides` all pass the row. So every read serves the override once, in the base's slot, under the base's `_packageId`, which is what N-10 already does for presentation types. **The binding the boot pull reads.** The boot pull reads `registry.listItems('flow')`, and a hydrated body's `_packageId` is not trustworthy (`withRowBinding` keeps a body's own stamp). So `hydrateOverlayIntoRegistry` records the row's `package_id` on the hydrated flow body as a non-enumerable `Symbol.for` property (`bindStoredRow`, read with `storedRowBindingOf`, both newly exported). That property cannot be authored in JSON, `JSON.stringify` drops it, and a spread or copy does not carry it. **The precedence (Wanted 1, 3, 4).** `classifyContender` asks first, and only inside a name the loader's set holds: is this the owner's declared override, by `isServedOverrideRowOf({ type: 'flow', package_id: storedRowBindingOf(item), metadata: item }, owner)`? If yes, it is an `override` contender, ranked 0, above `package` (1) and `runtime` (2). An override armed over its own base's body writes no receipt and no warning: that is the customized state, not a collision. The ledger identity is unchanged, because the override keeps the base's name and `packagedFlowOwner` still answers the base's package. The four arming steps already go through `resolveFlowContenders`, and `registerFlow` re-binds in one synchronous step, so the syncs needed no code change. **Delete restores the base (Wanted 3).** A `/meta` delete naming the pair, of a flow, is admitted past the sealed-removal refusal and told to the store as `runtime-only`, as the declared save is (`isPairRowRemoval`). Without this, the override could be saved but never removed through the door. The real-engine test found it. ⛔ No `packages/spec/**` and no `docs/adr/**` hunk. No new error code. ## Tests <details><summary>New cases, and red first on main (34 red across 5 suites)</summary> Production sources restored to main `0b6a52e8` (the new `stored-row-binding.ts` helper kept so the tests compile), `metadata-protocol` and `service-automation` rebuilt: - `service-automation` `flow-name-shadowing` + `flow-precedence-loader-set` + `flow-sync-one-precedence`: `Tests 14 failed | 41 passed (55)`. Red: the real-registry boot pull in both registration orders, the override classification, override armed with no receipt, the remedy sentence on all five not-admitted rows, override beside an undeclared row, the boot pull, `kernel:ready`, `metadata:reloaded` and the mutation sync. - `metadata-protocol` `protocol.flow-stored-row-shipped-name` + `protocol.packaged-base-refusal`: `Tests 15 failed | 71 passed (86)`. Red: the execution view, the served list, `listServedOverrides`, the hydrated binding, delete serving the loader again, the save sentence pins, and the declared save at the save door, `publishMetaItem` and `publishPackageDrafts`. - `objectql` `protocol-flow-custom-override` (new, real `ObjectQL` engine, packages installed through the package door): `Tests 3 failed | 1 passed (4)`. Red: save, publish and every read (by-name with and without the package, layered `code`/`effective`, served list, execution view); the undeclared remedy; delete serving X's flow again. - `cloud-connection` install gate: `Tests 1 failed | 6 passed (7)`. `cli` `override-declarations` (the `os validate` judge): `Tests 1 failed | 6 passed (7)`. Green on main by construction, and kept as pins: the "not admitted" cases (a dependent's declaration, a package-less row stamping the pair, a body that only claims the pair, the pair's undeclared row, another base's pair), the ledger off/on case, and the N-12 case. The ledger identity and version pinning are inherited by the override, not added here. Restore: `git checkout HEAD -- packages`, `git status --porcelain` empty, both packages rebuilt. </details> <details><summary>Ablations, one per arming step (each red, restored by blob hash)</summary> `node scripts/ablation-replace.mjs` on the committed head, under the verify lock. `metadata-protocol` was rebuilt in each leg and checked with `ablation-dist-preflight.mjs`. 1. **Precedence override rank** (`flow-precedence.ts`, the `isServedOverrideRowOf` branch gated off): `Tests 9 failed | 46 passed (55)`. Each step is red on its own: `× the boot pull arms the override over the loader's body`, `× the kernel:ready sync arms the override`, `× the metadata:reloaded re-sync: publishing the override arms it…`, `× the metadata-mutation sync: publishing the override arms it…`, plus the real-registry and pure-precedence cases. 2. **View exemption** (`protocol-lock-state.ts`, every stored row of a shipped flow name declined): `metadata-protocol` `Tests 5 failed | 81 passed`; `objectql` `× the save door admits… every read serves it` (`by-name (no package): expected 'SHIPPED' to be 'OVERRIDE'`) and `× deleting the override serves X's flow again`. 3. **Hydrated binding** (`protocol.ts`, `bindStoredRow` skipped): `× the hydration records the row's own package_id…`; `objectql` `expected [ undefined, undefined ] to include 'com.example.showcase.custom'`. 4. **Pair-row delete** (`protocol.ts`, the delete told to the store as `override-artifact`): `objectql` `× deleting the override serves X's flow again`. After each leg: `ablation-replace: ok restored: blob == HEAD … git diff HEAD is empty`. After all four: `metadata-protocol` rebuilt, `ablation-dist-preflight --absent` exit 0 for ABL1 to ABL4, `git status --porcelain` empty. </details> <details><summary>Pins whose meaning changed (updated in the same commit)</summary> - `metadata-protocol` `protocol.packaged-base-refusal.test.ts`: the flow save sentence now names the declaration (the literal pin is rewritten); the "declared flow override is refused" case becomes five cases (pair admitted at the save door, `publishMetaItem`, `publishPackageDrafts`; partner refused "only BASE.custom may declare"; pair undeclared refused with declare, no clone). - `metadata-protocol` `protocol.sealed-remedy-kind.test.ts`: a Regime C type whose override is open names the declaration in place of its clone route. - `metadata-protocol` `protocol.register-item-call-sites.test.ts`: the hydrator's `registerItem` arguments are now `type, registered, 'name'` (the body is built one line above so the binding can be recorded). The disposition is unchanged. - `rest` `rest-meta-packaged-flow-refusal.test.ts` and `runtime` `automation-packaged-base-lock.test.ts`: a save names the declaration and not the clone; a delete still names the clone. - `cli` `override-declarations.test.ts`: a declared flow is admitted now; the "not yet available" row moves to a declared `action`. </details> <details><summary>Full suites, typecheck, lint, gates</summary> - `metadata-protocol` full suite: `Test Files 2 failed | 289 passed`, `Tests 4 failed | 21954 passed` before the call-site and Regime C pin updates above; after them, both files pass (5/5 and 13/13). - `service-automation` shadowing/precedence/sync: `Tests 55 passed (55)`. `objectql` new file `4 passed`. `cloud-connection` install gate `7 passed`. `cli` `override-declarations` `7 passed`, `validate-override-declarations` `2 passed`. `rest` `4 passed`. `runtime` `15 passed`. - `pnpm --filter PKG run typecheck`: exit 0 for metadata-protocol, service-automation, objectql, cli, rest, runtime, cloud-connection. - eslint (`pnpm lint`'s invocation) over those seven packages: exit 0. - Derived gate list (`dispatch-gates.mjs --commands`, 68 families) plus the four roster gates under touched directories (`check:authz-resolver`, `check:changeset-fixed`, `check:error-code-casing`, `check:route-ledger-census`): all exit 0. On the first pass, `check:dual-build-cjs-loads`, `check:i18n`, `check:i18n-coverage` and `check:i18n-walk-parity` refused with exit 3 (unbuilt workspace packages, nothing measured), and the teardown self-test with exit 3 (its pinned fixture commit was missing from the shallow clone). Each was re-run after the build and the fetch, and exited 0. `--ran`: `68 derived, 68 run, 0 NOT-MEASURED, 0 UNRUN`. - Serial check: open PRs #28674, #28671 and #28669 touch `metadata-protocol` or `service-automation` sources. `git merge-tree` of each head against this head is clean, and none pins the flow refusal or the clone sentence. </details> ## Acceptance notes - **Two code packages shipping one flow name.** There the view keeps several items for the name, and its copies do not carry the binding (a copy never does), so the override ranks by the existing package rule there. This is not the D9 case (one base and its pair), and no test claims it. - **One bare registry slot per name.** The registry holds one stored row per bare name. A package-less row stored beside the pair's row could take that slot, so the boot pull would see that row instead of the override, while `kernel:ready` arms the override from the view a moment later. Rows like that are what the gate now refuses; N-16 migrates the old ones. - **Delete is admitted only when it names the pair.** A delete naming no package keeps the sealed refusal it had. Studio's affordance for this is N-18's (#28570). - **The read envelope.** The read envelope's `deletable` for the served override still follows the base's seal. Studio reads it, and N-18 owns that surface. - **Dogfood:** the full battery (`pnpm turbo run build --filter=@objectstack/dogfood` then `pnpm turbo run test --filter=@objectstack/dogfood`, unsharded) on head `0e67bba0`: `Test Files 369 passed | 3 skipped (372)`, `Tests 2981 passed | 28 skipped (3009)`, VERDICT command-exit 0. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01FEkQ5oTRCUNQ4EA3YV84sZ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7a48bf7 commit 3ee6af4

23 files changed

Lines changed: 1423 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/cloud-connection/src/marketplace-install-local-override-gate.test.ts‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,8 @@ const BASE_HOLDINGS: Holdings = {
110110
[BASE]: {
111111
dashboard: [{ name: 'crm_board', label: 'Board' }],
112112
object: [{ name: 'crm_account' }],
113+
flow: [{ name: 'crm_followup', label: 'Follow up' }],
114+
action: [{ name: 'crm_call', label: 'Call' }],
113115
},
114116
};
115117

@@ -179,6 +181,23 @@ describe('install-local 2d — the override write gate at the managed install (A
179181
expect(res.payload.data).not.toHaveProperty('overrideCollisions');
180182
});
181183

184+
it('[#28412] the paired .custom installs its declared FLOW override; a declared action override still waits', async () => {
185+
// ADR-0146 D9: flow's conditions are delivered, so the gate admits it.
186+
const flow = await install({
187+
id: PAIR, version: '1.0.0', dependencies: deps(BASE),
188+
flows: [{ name: 'crm_followup', label: 'Ours', overrides: { package: BASE, basedOn: '1.0.0' } }],
189+
});
190+
expect(flow.res.status, JSON.stringify(flow.res.payload)).toBe(200);
191+
expect(flow.registered).toEqual([PAIR]);
192+
193+
const action = await install({
194+
id: PAIR, version: '1.0.0', dependencies: deps(BASE),
195+
actions: [{ name: 'crm_call', label: 'Ours', overrides: { package: BASE, basedOn: '1.0.0' } }],
196+
});
197+
expect(action.res.status).toBe(403);
198+
expect(refusedOf(action.res)).toEqual([['action', 'crm_call', 'declared_type_not_yet_available']]);
199+
});
200+
182201
it('CONTROL: a dependent shipping its own names installs, and its answer carries no collision key', async () => {
183202
const { res, registered } = await install({
184203
id: PARTNER, version: '1.0.0', dependencies: deps(BASE), dashboards: [{ name: 'partner_board' }],

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,12 @@ export type { ServedOverrideRow } from './served-override.js';
244244
export type { ServedOverrideEntry } from './protocol-served-overrides.js';
245245
export type { PackagelessOverlayCensus, PackagelessOverlayMigration } from './protocol-packageless-overlay-migration.js';
246246

247+
// [#28412, ADR-0146 D9 item 1] The `package_id` column of the stored row a
248+
// registry body was hydrated from, recorded by the protocol's hydration and
249+
// read by the automation boot pull, which judges a flow override on the
250+
// column (`isServedOverrideRowOf`), ⛔ never on the body's `_packageId`.
251+
export { bindStoredRow, storedRowBindingOf } from './stored-row-binding.js';
252+
247253
// [ADR-0131 D6] The `unmanaged` install mode's walled-posture refusal — the
248254
// function `importUnmanagedPackage` raises, exported so the install door
249255
// (`@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: 83 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,83 @@ 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 a type whose D9 conditions are
250+
* delivered ({@link CONDITIONS_DELIVERED}: `flow` today) — the row the pair's
251+
* declared override is stored in. Such a removal is admitted past the
252+
* sealed-removal refusal: it removes no managed definition, and the code
253+
* package's flow is what every read serves and what the automation engine
254+
* arms once the row is gone — the narrowing direction the #6960 ruling names.
255+
* ⛔ Only the pair, named: a removal naming no package, or any other package,
256+
* keeps the sealed refusal it had. ⛔ Only a delivered type: every other open
257+
* type (`view`, `mapping`, …) keeps main's delete verdict — its own removal
258+
* carve-out or its sealed refusal — untouched by this card.
259+
*/
260+
export function isPairRowRemoval(type: string, basePackageId: unknown, packageId: unknown): boolean {
261+
const singular = PLURAL_TO_SINGULAR[type] ?? type;
262+
if (!CONDITIONS_DELIVERED.has(singular) || !isOpenOverrideType(singular)) return false;
263+
if (typeof basePackageId !== 'string') return false;
264+
if (typeof packageId !== 'string' || packageId.length === 0) return false;
265+
const pair = customizationPackageId(basePackageId);
266+
return pair.ok && pair.value === packageId;
199267
}
200268

201269
/** One item written into one package — what the override write gate judges. */
@@ -312,9 +380,10 @@ export function judgeOverrideWrite(
312380
`${item} carries \`overrides\`, but a ${type} takes no override (ADR-0146 D6). `
313381
+ `Remove \`overrides\` and create your own ${type} under a new name ("new similar").`);
314382
}
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.
383+
// `action` is classified overridable and waits for D9's conditions
384+
// (`isOpenOverrideType`): "action customization is not yet available".
385+
// [#28412] `flow`'s conditions are delivered, so it is judged below
386+
// like every open type.
318387
if (!isOpenOverrideType(type)) {
319388
return refuse('declared_type_not_yet_available',
320389
`${item} carries \`overrides\`: ${type} customization is not yet available, so a declared ${type} `

0 commit comments

Comments
 (0)