Repository navigation
Commit bcdf8d3
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
File tree
- .changeset
- packages
- cli/src/utils
- cloud-connection/src
- metadata-protocol/src
- objectql/src
- rest/src
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
98 | 98 | | |
99 | 99 | | |
100 | 100 | | |
101 | | - | |
102 | | - | |
| 101 | + | |
| 102 | + | |
103 | 103 | | |
104 | 104 | | |
105 | 105 | | |
106 | 106 | | |
107 | 107 | | |
108 | 108 | | |
109 | | - | |
110 | 109 | | |
111 | 110 | | |
112 | 111 | | |
113 | 112 | | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
114 | 133 | | |
115 | 134 | | |
116 | 135 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
122 | 122 | | |
123 | 123 | | |
124 | 124 | | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
125 | 129 | | |
126 | 130 | | |
127 | | - | |
| 131 | + | |
128 | 132 | | |
129 | 133 | | |
130 | 134 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
195 | 195 | | |
196 | 196 | | |
197 | 197 | | |
198 | | - | |
199 | | - | |
| 198 | + | |
| 199 | + | |
200 | 200 | | |
201 | 201 | | |
202 | 202 | | |
| |||
209 | 209 | | |
210 | 210 | | |
211 | 211 | | |
212 | | - | |
213 | | - | |
| 212 | + | |
| 213 | + | |
| 214 | + | |
| 215 | + | |
| 216 | + | |
| 217 | + | |
| 218 | + | |
| 219 | + | |
| 220 | + | |
| 221 | + | |
| 222 | + | |
| 223 | + | |
| 224 | + | |
| 225 | + | |
| 226 | + | |
| 227 | + | |
| 228 | + | |
| 229 | + | |
| 230 | + | |
| 231 | + | |
214 | 232 | | |
215 | 233 | | |
216 | 234 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
3688 | 3688 | | |
3689 | 3689 | | |
3690 | 3690 | | |
3691 | | - | |
| 3691 | + | |
| 3692 | + | |
| 3693 | + | |
| 3694 | + | |
| 3695 | + | |
| 3696 | + | |
| 3697 | + | |
3692 | 3698 | | |
3693 | 3699 | | |
3694 | | - | |
| 3700 | + | |
3695 | 3701 | | |
3696 | 3702 | | |
3697 | 3703 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
190 | 190 | | |
191 | 191 | | |
192 | 192 | | |
193 | | - | |
194 | | - | |
| 193 | + | |
| 194 | + | |
| 195 | + | |
| 196 | + | |
| 197 | + | |
195 | 198 | | |
196 | 199 | | |
197 | 200 | | |
| |||
319 | 322 | | |
320 | 323 | | |
321 | 324 | | |
322 | | - | |
| 325 | + | |
| 326 | + | |
323 | 327 | | |
324 | 328 | | |
325 | 329 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
19 | 19 | | |
20 | 20 | | |
21 | 21 | | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
22 | 27 | | |
23 | 28 | | |
24 | 29 | | |
| |||
155 | 160 | | |
156 | 161 | | |
157 | 162 | | |
158 | | - | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
159 | 168 | | |
160 | 169 | | |
161 | 170 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
349 | 349 | | |
350 | 350 | | |
351 | 351 | | |
352 | | - | |
353 | | - | |
354 | | - | |
| 352 | + | |
| 353 | + | |
| 354 | + | |
| 355 | + | |
355 | 356 | | |
356 | 357 | | |
357 | 358 | | |
| |||
0 commit comments