You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit 3ee6af4
Browse filesBrowse the repository at this point in the historyBrowse 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>
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.
0 commit comments