Skip to content

Commit bcdf8d3

Browse files
feat(metadata-protocol,objectql): open the declared override for a package's action (ADR-0146 D9) (#30497)
domain:server#3 · os-bill · session_01Utp6CYKQNa91PVtcbE6bGe <!-- claude-projects-attribution: {"github_login":"os-bill"} --> _Requested by **Bill** · [project thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wgQ1sUrf5sRiixFWe5kVVx7u)_ Fixes #30065 Clause-②: yes (widening) Metadata-protocol: added Skills-docs: follow-up #30505 ## 维护者速读 1. 改了什么:包内动作可以在配对的 `.custom` 包里用 `overrides` 声明覆盖,和 flow 一样走 ADR-0146 D9。原来这类保存一律 403 "not yet available",现在放行。 2. 三个条件:每一种读(含对象读的 `actions`、运行门读的 `getSchema`、分层读 `effective`)都按基包的名字返回覆盖;删除覆盖后各处读回基包;开关账本按名字一行管住当前武装的定义。 3. 脚本 body 换绑:引擎的 handler 改成栈,覆盖压在基包 handler 上面,恢复原版后基包 handler 还在,不需要重新注册。有一条专门的测试钉住"覆盖、恢复、再运行,跑的是基包 body"。 4. 验收 3(席位选 A):覆盖不能扩大谁能运行。丢掉 `requiredPermissions` 里的能力、`ai.exposed` 从关到开、去掉 `ai.requiresConfirmation: true`,都会以新规则 `declared_action_widens_access` 返回 403 `NOT_OVERRIDABLE`。保存、发布、`os validate` 和安装门共用这一道闸门。 5. 仍然允许的:行为字段整份替换,所以覆盖可以换掉实际运行的内容,包括在系统提权下运行的 body,但只对原本就能运行这个动作的人生效。这一条请走合同级复核。 6. 范围:不改 `packages/spec`、`skills/**` 和其他受管面,`ui/**` 只读;skills 文档的补改由席位另开卡。changeset 对四个包都是 minor。 ## What changes ADR-0146 D9 lists `action` as overridable "once its door is cut". This PR cuts that door, at the grain of the flow override (#28412). **Write gate** (`packages/metadata-protocol/src/protocol-packaged-base-refusal.ts`) - `action` joins `CONDITIONS_DELIVERED`. A declared override from the base's paired `.custom` is admitted by the save, publish and install doors, `os validate` and `os build`. Only the pair declares: a dependent's declaration and the pair's undeclared same-named save stay refused, and their remedies are unchanged. - New rule `declared_action_widens_access` (`403 NOT_OVERRIDABLE`). An override may add a gate and never remove one (ADR-0066 D4). Refused when the override: - drops a capability the base's `requiredPermissions` names; - sets `ai.exposed: true` on an action the base keeps off the agent surface; - drops the base's `ai.requiresConfirmation: true`. The refusal names each widened gate. `actionOverrideWidening(baseItem, overrideItem)` is exported. - `OverrideWriteClosure.baseItemOf` lets each door hand the judge the base's own item: the protocol doors (from the shipped artifact), the CLI walk (from the base in the stack) and the managed install (from the base's held items). - The action switch route in the packaged-base refusal sentences is spelled `POST /api/v1/actions/_activation/:object_or_global/:action`. With the longer route, the new declared-save sentence for an 88-character action name ran to 520 characters and crossed the REST door's 500-character client-message bound. It is now 487. **Reads** (`protocol-lock-state.ts`) - `declinesStoredRow` serves the pair's declared action row, so the layered read's `effective` (and the metadata service's by-name read) answer the override. The other reads already went through the served-override resolver once the type was open. The action keeps its bare registry entry, as a flow does, so `SEALED_ROW_SERVED_OVERRIDE_TYPES` is unchanged. **Script body rebinding** (`packages/objectql`) - `ObjectQL#registerActionOverride(objectName, actionName, handler, packageName)` binds a handler over the one registered under the key and keeps it beneath (`ActionHandlerEntry.beneath`). Re-binding the same package's override keeps the same handler beneath. - `removeActionsByPackage(packageName)` removes the package's entries from each key's stack, so removing an override binds the handler beneath again. Removing the base under an armed override keeps the override. - `resyncAuthoredActions` no longer filters out a stored row that is the declared `.custom` override of an artifact-shipped action on the same object (judged by the same `isServedOverrideRowOf` the reads use). Such a row is bound with `registerActionOverride`, so one resync rebinds the name in either direction. A row whose base the registry does not hold yet is judged by its own declaration (`isServedOverrideRowOf(row, overrides.package)`) and bound as an override too. - `registerAction` of another package's handler under a key an override holds takes the place beneath the override (the entry is marked `override`) instead of replacing it. This is what keeps condition 2 after a restart for a marketplace-installed base: its handlers are bound by the install plugin's `kernel:ready` rehydrate (`bindAppArtifactHandlers` → `registerAction(..., 'app:<base>')`), which runs after ObjectQLPlugin's `kernel:ready` resync bound the override and announces no `metadata:reloaded`. Before this, the rehydrate replaced the override's handler, so every read served the override while `executeAction` ran the base's body. ## Why #30065: a customer should be able to rename, hide or adjust a button a package ships without cloning it. Before this PR the declared override was refused. And merely opening the type (measured with a temporary flip of the set) would have left two gaps. First, the layered read kept serving the base. Second, a script action would have kept running the base's body: the resync filtered the override row out as artifact-shipped, and binding it with `registerAction` would have lost the base's handler on restore. A third problem showed up in the same measurement: an override carrying `requiredPermissions: []` was served as-is, so the override could remove ADR-0066 D4's capability gate from the base's button. That is what Acceptance 3 asked about. The seat chose to refuse the widening at the write gate. What an override can still change: every behaviour field (`label`, `type`, `target`, `body`, `params`, `visible` and the rest) is replaced whole, so an override can change what runs, including a body that runs system-elevated. It only does so for people who could already run the action: the gates are the base's, or stricter. Boot order: a base's handlers may be bound before the resync (a code-loaded package, in `AppPlugin.start`) or after it (a marketplace-installed base, in the install plugin's `kernel:ready` rehydrate). Both orders end with the override on top and the base's handler beneath it, and a re-bind of the base's handlers while the override is armed does too. ### Out of scope (follow-ups) All four are filed as #30542 (the two below, plus the self-declared binding before the base registers and the slot-beneath log level from the delta review). - The install door judges a re-install of `.custom` against the base's items as `getMetaItems({ type, packageId: base })` serves them when the base is not in the same delivery. Once an override is armed, that read serves the override under the base's envelope, so a v2 that relaxes a v1 tightening back to the base's own gates is refused at install although the protocol door admits it. Over-refusal only (fails closed). - The protocol door matches an action override's base by name alone (`ITEM_KEY_DISCRIMINATORS` has no `action` row), while `os validate` and the install door key it `<objectName>:<name>`. An override may therefore move a base action to another object at the save door. It widens nothing (the handler is not rebound and the base's gates ride the declaration), but the doors disagree. ## Verification Tests at the flow override's grain: - `packages/objectql/src/protocol-flow-custom-override.test.ts`, new `describe` "[#30065] a declared .custom action override is the packaged action, at every door". It uses a real protocol over a real ObjectQL engine with a memory driver and covers: - condition 1: every read (by-name, by-name under the base, execution, layered `effective`, both lists, the object read's `actions`, the object list, `getSchema`) serves the override under the base's `_packageId`, and the run doors read the override's added capability; - condition 2: deleting the override serves the base everywhere; - condition 3: `setActionActive({ name, packageId: BASE, active: false })` turns the armed override off, the object read's `disabledActions` lists it, and the switch survives the restore; - widening refused (`code` + `status`), nothing stored; - a dependent's declaration refused and, laid down directly, never served; - the pair's undeclared save refused, with a remedy that names the declaration and never a clone. - `packages/objectql/src/plugin-authored-actions.test.ts`, new `describe` on a real engine with the base handler registered as `app:<id>`. It pins override → restore → run again (the base body runs); a base bound after the override's resync, as a marketplace base's rehydrate binds it (the override runs, then a restore runs the base body); the base's handlers re-bound while the override is armed; re-binding; undeclared and dependent rows never binding, before and after the base is registered, and an other-base row never binding once the base is registered (before that, see the follow-up #30542); a body-less override; and the handler stack itself. - `packages/metadata-protocol/src/protocol.packaged-base-refusal.test.ts`: admitted narrowing, three widening refusals, a publish of a seeded widening draft refused. - `packages/cli/src/utils/override-declarations.test.ts` and `packages/cloud-connection/src/marketplace-install-local-override-gate.test.ts`: the action declaration is admitted, and a widening is refused at `os validate` and at the managed install (403, nothing written). - `packages/rest/src/rest-meta-packaged-action-permission-refusal.test.ts`: the 88-character name sentence stays under the bound, with the declare remedy. Runs (local): full suites of metadata-protocol (22414 passed) and objectql (10998 passed at the rework head); targeted cli, cloud-connection and rest suites passed; `tsc --noEmit` clean on metadata-protocol, objectql and cloud-connection; eslint clean on the changed files; `dispatch-gates --ran` 65 of 65 run, none unmeasured; `check:liveness` and `check:platform-checklist` green. ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (`packages/spec` not touched.) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: not touched. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. - [x] The *Before Submitting* list in CONTRIBUTING.md is done. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01Utp6CYKQNa91PVtcbE6bGe --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent a4c3e73 commit bcdf8d3

17 files changed

Lines changed: 719 additions & 62 deletions
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@objectstack/metadata-protocol': minor
3+
'@objectstack/objectql': minor
4+
'@objectstack/cli': minor
5+
'@objectstack/cloud-connection': minor
6+
---
7+
8+
feat(metadata-protocol,objectql): a package's action can be customized by a declared override in its paired `.custom` package (ADR-0146 D9 `action`), and an override that widens who may run the action is refused
9+
10+
Clause-②: yes (widening)
11+
Metadata-protocol: added
12+
13+
- **A declared action override is admitted.** An `action` saved, published or installed in a base's paired `<base>.custom` package with `overrides: { package: '<base>', basedOn: '<version>' }` was refused `403 NOT_OVERRIDABLE` ("action customization is not yet available"). It is now admitted by the save, publish and install doors, `os validate` and `os build`, like a flow override. It replaces the base's action whole, and every read of the base's action serves it under the base's name: the by-name and execution reads, the layered read's `effective` (with `code` still the base's), both lists, the object read's `actions` (what the list toolbar, row menu and record header are drawn from) and the engine's schema, which the REST `/actions` door and the MCP `run_action` bridge read the action from. Deleting the override serves the base's action again everywhere. The action activation switch keyed by the action's name turns the override off while it is armed, and the switch stays where it was when the base is restored. Only the pair declares: a dependent's declaration and the pair's undeclared same-named save stay refused, and a dependent's row is never served.
14+
- **A script action's body is rebound.** The override's `body` runs in place of the base's handler after one resync (`kernel:ready`, `metadata:reloaded`, or the publish itself). Removing the override binds the base's handler again on the next resync, without re-registering the base. `@objectstack/objectql` adds `ObjectQL#registerActionOverride(objectName, actionName, handler, packageName)`, which binds a handler over the one registered under the key and keeps it beneath. `removeActionsByPackage(packageName)` now removes the package's entries from each key's stack, so the handler beneath is bound again. `registerAction` of another package's handler under a key an override holds takes the place beneath the override instead of replacing it: a marketplace-installed base binds its handlers in the install plugin's `kernel:ready` rehydrate, after the boot's resync bound the override, and after a restart the override still runs (and a restore runs the base's body).
15+
- **An override may add a gate and never remove one (ADR-0066 D4).** The write gate refuses, with the new rule `declared_action_widens_access` (`403 NOT_OVERRIDABLE`), an action override that drops a capability the base's `requiredPermissions` names, sets `ai.exposed: true` on an action the base keeps off the agent surface, or drops the base's `ai.requiresConfirmation: true`. The refusal names each widened gate. Adding a capability or turning a gate on is admitted. Everything else (`label`, `type`, `target`, `body`, `params`, `visible` and the rest) is replaced whole, so an override can still change what runs, including a body that runs system-elevated, but only for people who could already run the action. `@objectstack/metadata-protocol` exports `actionOverrideWidening(baseItem, overrideItem)`, which returns the widened gates as clauses, and `OverrideWriteClosure.baseItemOf`, through which a door hands the judge the base's own item. `@objectstack/cli` (`os validate`) and `@objectstack/cloud-connection` (the managed install) pass it.
16+
- The action switch route in the packaged-base refusal sentences is now spelled `POST /api/v1/actions/_activation/:object_or_global/:action`, which keeps the declared-save sentence within the REST door's 500-character message bound for long action names.
17+
18+
Skills-docs: follow-up #30505

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

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -98,19 +98,38 @@ 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.
101+
// [#28412, #30065, ADR-0146 D9] Declared flow and action overrides are
102+
// admitted (their D9 conditions are delivered).
103103
flows: [{ name: 'crm_flow', overrides: { package: BASE, basedOn: '1.0.0' } }],
104104
actions: [{ name: 'crm_call', objectName: 'crm_account', label: 'Call', overrides: { package: BASE, basedOn: '1.0.0' } }],
105105
pages: [{ name: 'crm_home', overrides: { package: 'com.unlisted', basedOn: '1.0.0' } }],
106106
});
107107
expect(rows.map((r) => [r.path, r.rule])).toEqual([
108108
['objects[0]', 'declared_object'],
109-
['actions[0]', 'declared_type_not_yet_available'],
110109
['pages[0]', 'declared_outside_pair'],
111110
]);
112111
});
113112

113+
it('[#30065] a declared action override is judged against the base\'s own action: a widening is refused, a narrowing admitted', () => {
114+
const gates = { requiredPermissions: ['crm_admin'], ai: { requiresConfirmation: true } };
115+
const gatedBase = pkg(BASE, [], { actions: [{ name: 'log_call', objectName: 'crm_account', label: 'Log', ...gates }] });
116+
const overrides = { package: BASE, basedOn: '1.0.0' };
117+
const wider = { name: 'log_call', objectName: 'crm_account', label: 'Ours', overrides, requiredPermissions: [], ai: { requiresConfirmation: true } };
118+
const narrower = { name: 'log_call', objectName: 'crm_account', label: 'Ours', overrides, ...gates, requiredPermissions: ['crm_admin', 'crm_auditor'] };
119+
const rows = findOverrideDeclarationRefusals({
120+
manifest: manifest('com.acme.bundle'),
121+
packages: [gatedBase, pkg(PAIR, [BASE], { actions: [wider] })],
122+
});
123+
expect(rows.map((r) => [r.path, r.code, r.rule])).toEqual([
124+
['packages[1].manifest.actions[0]', 'NOT_OVERRIDABLE', 'declared_action_widens_access'],
125+
]);
126+
expect(rows[0].message).toContain("drops 'crm_admin' from `requiredPermissions`");
127+
expect(findOverrideDeclarationRefusals({
128+
manifest: manifest('com.acme.bundle'),
129+
packages: [gatedBase, pkg(PAIR, [BASE], { actions: [narrower] })],
130+
})).toEqual([]);
131+
});
132+
114133
it('a dependent carrying `overrides` is refused, and a declaration of an item the base does not ship is refused', () => {
115134
const rows = findOverrideDeclarationRefusals({
116135
manifest: manifest('com.acme.bundle'),

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,9 +122,13 @@ export function findOverrideDeclarationRefusals(parsed: Record<string, unknown>)
122122
if (base !== undefined && dependencies.includes(base) && !byId.has(base) && !shippers.includes(base)) {
123123
shippers.push(base);
124124
}
125+
// [#30065] The base's own item of this identity, when this artifact
126+
// carries the base: what a declared action override is judged against.
127+
const baseItemOf = (baseId: string): unknown =>
128+
byId.get(baseId)?.items.find((i) => `${i.type}\u0000${i.identity}` === key)?.item;
125129
const verdict = judgeOverrideWrite(
126130
{ type: walked.type, name: walked.name, packageId: pkg.id, item: walked.item },
127-
{ dependencies, shippers },
131+
{ dependencies, shippers, baseItemOf },
128132
);
129133
if (verdict.verdict !== 'refuse') continue;
130134
rows.push({ path: walked.path, code: 'NOT_OVERRIDABLE', rule: verdict.rule, packageId: pkg.id, message: verdict.message });

‎packages/cloud-connection/src/marketplace-install-local-override-gate.test.ts‎

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -195,8 +195,8 @@ describe('install-local 2d — the override write gate at the managed install (A
195195
expect(res.payload.data).not.toHaveProperty('overrideCollisions');
196196
});
197197

198-
it('[#28412] the paired .custom installs its declared FLOW override; a declared action override still waits', async () => {
199-
// ADR-0146 D9: flow's conditions are delivered, so the gate admits it.
198+
it('[#28412, #30065] the paired .custom installs its declared FLOW and ACTION overrides', async () => {
199+
// ADR-0146 D9: flow's and action's conditions are delivered, so the gate admits them.
200200
const flow = await install({
201201
id: PAIR, version: '1.0.0', namespace: 'crm_custom', dependencies: deps(BASE),
202202
flows: [{ name: 'crm_followup', label: 'Ours', overrides: { package: BASE, basedOn: '1.0.0' } }],
@@ -209,8 +209,26 @@ describe('install-local 2d — the override write gate at the managed install (A
209209
id: PAIR, version: '1.0.0', namespace: 'crm_custom', dependencies: deps(BASE),
210210
actions: [{ name: 'crm_call', label: 'Ours', overrides: { package: BASE, basedOn: '1.0.0' } }],
211211
});
212-
expect(action.res.status).toBe(403);
213-
expect(refusedOf(action.res)).toEqual([['action', 'crm_call', 'declared_type_not_yet_available']]);
212+
expect(action.res.status, JSON.stringify(action.res.payload)).toBe(200);
213+
expect(action.registered).toEqual([]);
214+
expect(action.saved).toEqual([['action', 'crm_call']]);
215+
});
216+
217+
it('[#30065] a declared action override that widens who may run the base\'s action is refused 403, and nothing is written', async () => {
218+
const holdings: Holdings = {
219+
[BASE]: { action: [{ name: 'crm_call', label: 'Call', requiredPermissions: ['crm_admin'], ai: { exposed: false } }] },
220+
};
221+
const wider = { name: 'crm_call', label: 'Ours', overrides: { package: BASE, basedOn: '1.0.0' }, requiredPermissions: ['crm_admin'], ai: { exposed: true } };
222+
const { res, registered, saved, ledger } = await install(
223+
{ id: PAIR, version: '1.0.0', namespace: 'crm_custom', dependencies: deps(BASE), actions: [wider] },
224+
[{ id: BASE }],
225+
holdings,
226+
);
227+
expect(res.status, JSON.stringify(res.payload)).toBe(403);
228+
expect(res.payload?.error?.code).toBe('NOT_OVERRIDABLE');
229+
expect(refusedOf(res)).toEqual([['action', 'crm_call', 'declared_action_widens_access']]);
230+
expect(res.payload.error.details.refusals[0].message).toContain('`ai.exposed: true`');
231+
expect([registered, saved, ledger]).toEqual([[], [], []]);
214232
});
215233

216234
it('CONTROL: a dependent shipping its own names installs, and its answer carries no collision key', async () => {

‎packages/cloud-connection/src/marketplace-install-local-plugin.ts‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3688,10 +3688,16 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
36883688
const bases = closure.filter(isBase);
36893689
for (const item of pkg.items) {
36903690
const shippers: string[] = [];
3691-
for (const base of bases) if ((await heldBy(item.type, base)).has(item.identity)) shippers.push(base);
3691+
// [#30065] …and each shipper's own item, what a declared
3692+
// action override is judged against.
3693+
const shipped = new Map<string, unknown>();
3694+
for (const base of bases) {
3695+
const held = (await heldBy(item.type, base)).get(item.identity);
3696+
if (held !== undefined) { shippers.push(base); shipped.set(base, held); }
3697+
}
36923698
const verdict = judgeOverrideWrite(
36933699
{ type: item.type, name: item.name, packageId, item: item.item },
3694-
{ dependencies: closure, shippers },
3700+
{ dependencies: closure, shippers, baseItemOf: (base) => shipped.get(base) },
36953701
);
36963702
if (verdict.verdict === 'refuse') {
36973703
refusals.push({

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

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -190,8 +190,11 @@ export const PACKAGED_BASE_REGIME: Readonly<Record<string, PackagedBaseRegimeRow
190190
action: {
191191
regime: 'C',
192192
routes: {
193-
switchOff: 'POST /api/v1/actions/_activation/:object/:action, body {enabled: false}, '
194-
+ ':object = global for an object-less action; '
193+
// [#30065] `:object_or_global` names the object-less spelling in
194+
// the segment itself: the declared-override save sentence adds the
195+
// declaration's clause, and the route must keep it under the REST
196+
// door's 500-character bound for an 88-character name.
197+
switchOff: 'POST /api/v1/actions/_activation/:object_or_global/:action, body {enabled: false}; '
195198
+ 'operator-only where one install serves several organizations',
196199
},
197200
},
@@ -319,7 +322,8 @@ export function isOriginGatedType(type: string): boolean {
319322
* Kept under the REST door's 500-character client-message bound
320323
* (`truncateClientMessage`, `packages/rest/src/error-response.ts`), past which
321324
* the tail is truncated. Characters outside the item's name, save / removal:
322-
* `flow` 395 / 388, `action` 349 / 342, `permission` 301 / 294,
325+
* `flow` 395 / 388, `action` 315 / 308 ([#30065] its declared-override save 398
326+
* with a 22-character pair id), `permission` 301 / 294,
323327
* `datasource` 192 / 193, `sharing_rule` 390 / 383 — so a name of up to 88
324328
* characters arrives whole for every row (pinned). [#21944] A row's `hostOwned` name is a fixed, short name
325329
* with its own remedy (`default`: under 300 characters whole).

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

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,11 @@
1919
* stored row it judges, and does not decline a shipped flow name's row that
2020
* the one served-override predicate (`isServedOverrideRowOf`) admits for the
2121
* shipping package — its declared `.custom` override, the packaged flow.
22+
* [#30065] The same admission holds for an `action` name's declared
23+
* override (ADR-0146 D9's `action` paragraph). Unlike the sealed row-served
24+
* types, an action's override row keeps its bare registry entry, as a flow's
25+
* does: the object read's `actions`, the run doors' first rung and the
26+
* authored-action handler resync read the registry, not the row.
2227
*
2328
* ## How the moved bodies reach the class
2429
*
@@ -155,7 +160,11 @@ export function declinesStoredRow(
155160
// above, on the row's own `package_id` and body, against the package
156161
// that ships the name. The reads then serve it from its row, as they
157162
// serve a dashboard's. Every other sealed row of the name stays declined.
158-
return !(servesSealedOverrideRow(type)
163+
// [#30065, ADR-0146 D9 `action`] …and for an `action`, judged the same
164+
// way: its declared override is served over the base's name from its
165+
// row, so the layered read's `effective` and a by-name read answered by
166+
// the metadata service see it as the registry's readers do.
167+
return !((servesSealedOverrideRow(type) || canonicalMetaType(type) === 'action')
159168
&& isServedOverrideRowOf(row, this.packagedArtifactOwner({ type, name })));
160169
}
161170

‎packages/metadata-protocol/src/protocol-package-bindings.ts‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -349,9 +349,10 @@ const BORROWED_ENVELOPE_DECLINED_TYPES: ReadonlySet<string> = new Set([
349349
* `import-mapping.ts`, included) answered the base's body, so a published
350350
* override did nothing.
351351
*
352-
* ⛔ A closed list, judged on the canonical type. `action` and `flow` wait for
353-
* ADR-0146 D9's conditions (`flow` has its own admission,
354-
* `declinesStoredRow`'s first branch). Every type here is also in
352+
* ⛔ A closed list, judged on the canonical type. `flow` and [#30065] `action`
353+
* are not here: ADR-0146 D9 admits their declared override in
354+
* `declinesStoredRow` on its own, and their override row keeps its bare
355+
* registry entry, which the engine's readers arm from. Every type here is also in
355356
* {@link BORROWED_ENVELOPE_DECLINED_TYPES}: the bare entry served no reader
356357
* the row does not serve now.
357358
*/

0 commit comments

Comments
 (0)