Repository navigation
Commit e44a1ff
fix(objectql)!: store a code package's action as its ActionSchema parse, so a bound action's requiresFeature is lowered (#25699)
Fixes #25649
Clause-②: no (narrowing) — `SchemaRegistry.registerItem`, and with it
`ObjectQL.registerApp` and every door that registers a code package's
items, now refuses with `422 INVALID_METADATA` an `action` body that
does not satisfy `ActionSchema`. Before, it registered that body as
written. Nothing is widened.
Metadata-protocol: none — `ActionSchema` is unchanged and no key is
added, retired or re-shaped. Every body the registry now refuses already
fails the same schema at `defineStack`, `os validate` and the metadata
save door, so what a metadata author can write under the protocol does
not move. The runtime door converges on it.
**BREAKING** for a package that registers an action body `ActionSchema`
does not accept. Such a body used to be stored as written, with its
declared gates inert. Now its registration is refused with
`INVALID_METADATA` (422), naming the action and its package, and
`issues[]` locates each failure. An action that passes `os validate` or
a strict `defineStack` is unaffected. ADR-0087 disposition (in the
changeset): `not-required (no-migration-prescription)`. `ActionSchema`
is byte-unchanged, so there is nothing for `objectstack migrate meta` to
rewrite.
## What changed
`SchemaRegistry.registerItem` (`packages/objectql/src/registry.ts`) now
stores a code package's `action` item as its `ActionSchema` parse. If
the item does not parse, the registry refuses it loudly. Before, the
item was stored as the package wrote it.
- **Where.** `registerItem` is the one site every code-package action
passes: `ObjectQL.registerApp`, nested plugins, the metadata facade and
the ObjectQL plugin's metadata-service sync all register through it. The
new step runs right after `applyProtection` and before anything is
stored. It applies when the type is `action`, a `packageId` is passed,
and `isCodeArtifactBody` holds. A registration with no `packageId` is
the bare slot (`sys_metadata` hydration, the write-through) and is not
judged here, the same rule the security-catalog and default-page checks
in that method follow.
- **Why the stored item has to be the parse.** `ActionSchema`'s
transform lowers `requiresMembershipReach` and `requiresFeature` into
`visible` and deletes both keys. No runtime code reads either key raw.
So an unparsed stored action carried both gates declared and inert, and
`attachBoundActions` served it that way. Platform objects' actions are
parsed where they are authored (`ObjectSchema.create`), which is why
`rename_organization` was lowered and cloud's bound `change_slug` was
not.
- **Identity kept.** The attached entry is still the very item the
router's rung 2 resolves (`toBe` pinned). `attachBoundActions` is
unchanged, so rung 1 before rung 2 and dedupe by name both hold.
- **Refusal.** An action that fails the parse throws the
`INVALID_METADATA` / 422 envelope (ADR-0112), the code this package
already uses for metadata that fails the spec. The message names the
action and its package. `issues[]` gives each failure with `path`,
`message` and `code`. No new error code.
## What the parse does to a code-package action (measured)
Measured on `@objectstack/spec` at this branch's base, with a stamped
body (`applyProtection`, package id and version):
- `requiresFeature` is lowered: `visible: "record.x == 1"` with
`requiresFeature: multiOrgEnabled` becomes `{ dialect: 'cel', source:
'(record.x == 1) && features.multiOrgEnabled != false' }`, and the key
is deleted. `requiresMembershipReach` is lowered the same way.
- Defaults are filled, e.g. `refreshAfter: false`, and `type` defaults
to `'script'`.
- A CEL string shorthand is wrapped as `{ dialect, source }`.
- An undeclared key is refused (`unrecognized_keys`; the schema is
closed).
- The ADR-0010 envelope (`_packageId`, `_packageVersion`, `_provenance`,
`_lock`) is declared on the schema and passes through.
- The parse is idempotent: `parse(parse(x))` equals `parse(x)`.
**In-repo shipped actions: none refused.** The registry door's accept
set narrows (above), but every top-level action the in-repo stacks ship
goes through a strict `defineStack` or `defineAction`, and all of them
parse. I ran each stamped action through `ActionSchema.safeParse` and
compared the canonical (key-sorted) JSON before and after:
| Stack | Actions | Bound | Refused | Changed |
|---|---|---|---|---|
| `examples/app-crm` | 1 | 1 | 0 | 0 |
| `examples/app-todo` | 8 | 8 | 0 | 0 |
| `examples/app-showcase` (`allActions`) | 72 | 71 | 0 | 0 |
No top-level `actions:` collection exists in `packages/plugins/**`,
`packages/services/**` or `packages/runtime/src/**` source. The refusal
reaches only the test fixtures triaged below.
## Tests
New, in `packages/objectql/src/bound-action-foreign-object.test.ts`,
through the real `ObjectQL.registerApp` and `protocol.getMetaItem({
type: 'object' })`, the read behind `GET /api/v1/meta/objects/:name`:
- A package-bound action with `requiresFeature: 'multiOrgEnabled'` and a
CEL `visible` is served with the conjunct lowered, `(record.name != '')
&& features.multiOrgEnabled != false`, and with no `requiresFeature`
key. The value equals what the platform object's own action with the
same declaration (`ObjectSchema.create`) is served. The attachment is
`toBe` the registry item.
- A bound action with no `visible` of its own is served the gate alone.
- Control: the platform object's own action is served exactly as
`ObjectSchema.create` produced it.
- Refusal: a bound action with `requiresFeature` beside `visible: false`
is refused with `INVALID_METADATA` / 422. The message names
`change_slug` and `com.test.binder`, `issues[]` contains `path:
'requiresFeature'`, and the item is not stored or attached.
**Ablation** (`scripts/ablation-replace.mjs`, wrap mode). The test
imports `./registry.js` from source, so no dist leg applies. The anchor
`item = parsePackageAction(item, baseName, packageId) as T;` hit once
and was replaced by a comment; the blob went `0aa44217` to `07883fa6`.
Result: 3 tests failed and 15 passed. The three red were the lowering,
gate-alone and refusal tests; the control stayed green, as intended.
Restore: blob `0aa44217` equals HEAD and `git diff HEAD` is empty.
### Fixture triage (the refusal's consumer radius)
Each fixture below registered an off-spec action body. Each one got the
missing declaration ("supply the declaration"). Nothing pinned the
removed behaviour:
- `objectql/src/package-disable-enforcement.test.ts`: the shipped
`action` specimen gains `type: 'url'` and `target`.
- `objectql/src/engine-nested-plugin-collections.test.ts`: the `actions`
probe gains `type: 'url'` and `target`.
- `objectql/src/registry-package-version-stamp.test.ts`: the `script`
action gains a `target`.
- `objectql/src/registry.test.ts`: the "generic metadata" cases now use
type `report`; `action` is no longer a generic item.
- `objectql/src/bound-action-collision-report.test.ts`: the owner's
object is authored with `ObjectSchema.create`, as an object is, so its
action is the parse of what was written, like the carrier's registered
copy.
- `runtime/src/domains/mcp-package-object-discovery.test.ts`: the AI
`description` is 40 characters or more, the length `ActionSchema`
requires.
- `runtime/src/stored-metadata-reader-contexts.pin.test.ts`: the
host-handler `script` action names its `target`. The runtime never reads
a `script` action's `target` server-side, since dispatch is by handler
key, so the pin's subject is unchanged.
## Local verification
Final head `f9330127c2`. The rework commit changes only the changeset.
- **Dogfood battery, full**, under the verify lock with CI's command
(`pnpm --filter @objectstack/dogfood test`, no `OS_TEST_SHARD`, no
paths), after building the `@objectstack/dogfood^...` closure: `Test
Files 295 passed | 3 skipped (298)`, `Tests 2520 passed | 18 skipped
(2538)`, exit 0. Lock: `VERDICT command-exit 0`.
- `@objectstack/objectql` local project, full: `Test Files 466 passed
(466)`, `Tests 8704 passed (8704)`, exit 0.
- `@objectstack/objectql` typecheck: exit 0, including
`check:test-typecheck: OK`.
- `node scripts/check-adr-0087-registration.mjs --base origin/main`:
exit 0. It reads one declared-breaking changeset,
`[BREAKING+bang+clause-②-narrowing]`, with disposition `not-required
(no-migration-prescription)`.
- `node scripts/check-changeset-no-major.mjs --base origin/main`: exit
0. With `--event` carrying this body, it also exits 0 and reads
`clause-② no (narrowing)`, metadata-protocol `none`, no changeset graded
`minor` or above.
- `node scripts/check-empty-changeset.mjs --base origin/main`: exit 0.
- `eslint --no-inline-config` over the 9 touched `.ts` files: exit 0.
- Earlier rounds, on code unchanged since: `@objectstack/runtime` local
project full (441 files; the one red file was triaged, and both triaged
files rerun green, 171 tests), runtime typecheck exit 0,
`@objectstack/rest` full (325 files), `@objectstack/metadata-protocol`
full (263 passed, 3 skipped). At `89ecd31788`, the 74 families
`dispatch-gates.mjs --commands` derived all exit 0 (`--ran`: 74 derived,
74 run, 0 NOT-MEASURED).
## Acceptance notes
- **Boundary: an object's own actions on a raw manifest stay unparsed.**
This PR does not change that seam. A raw manifest or a `strict: false`
stack registered with `registerApp` stores its objects' own `actions`
unparsed, because the object path's `validate` is a diagnostic only.
Measured: a probe registering `{ objects: [{ …, actions: [{ name:
'gated', …, requiresFeature: 'multiOrgEnabled' }] }] }` reads back from
`protocol.getMetaItem({ type: 'object' })` with `requiresFeature` raw
and no `visible`. This is the same declared-not-enforced family; the
seat carries it, outside this PR.
- **Boundary: tenant overlay rows.** A stored overlay of a packaged
action is registered in the bare slot and is not parsed here. It is
parsed at the metadata save door. Whether that door stores the parsed
body or the authored one was not measured.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01JVTd3PprStGa26aez5omLB)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 3b8ea15 commit e44a1ff
10 files changed
Lines changed: 218 additions & 16 deletions
File tree
- .changeset
- packages
- objectql/src
- runtime/src
- domains
| 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 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
18 | 18 | | |
19 | 19 | | |
20 | 20 | | |
| 21 | + | |
21 | 22 | | |
22 | 23 | | |
23 | 24 | | |
24 | 25 | | |
25 | | - | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
26 | 31 | | |
27 | 32 | | |
28 | | - | |
| 33 | + | |
29 | 34 | | |
30 | 35 | | |
31 | 36 | | |
32 | | - | |
| 37 | + | |
33 | 38 | | |
34 | 39 | | |
35 | 40 | | |
| |||
41 | 46 | | |
42 | 47 | | |
43 | 48 | | |
44 | | - | |
| 49 | + | |
45 | 50 | | |
46 | 51 | | |
47 | 52 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
20 | 20 | | |
21 | 21 | | |
22 | 22 | | |
| 23 | + | |
23 | 24 | | |
24 | 25 | | |
25 | 26 | | |
| |||
351 | 352 | | |
352 | 353 | | |
353 | 354 | | |
| 355 | + | |
| 356 | + | |
| 357 | + | |
| 358 | + | |
| 359 | + | |
| 360 | + | |
| 361 | + | |
| 362 | + | |
| 363 | + | |
| 364 | + | |
| 365 | + | |
| 366 | + | |
| 367 | + | |
| 368 | + | |
| 369 | + | |
| 370 | + | |
| 371 | + | |
| 372 | + | |
| 373 | + | |
| 374 | + | |
| 375 | + | |
| 376 | + | |
| 377 | + | |
| 378 | + | |
| 379 | + | |
| 380 | + | |
| 381 | + | |
| 382 | + | |
| 383 | + | |
| 384 | + | |
| 385 | + | |
| 386 | + | |
| 387 | + | |
| 388 | + | |
| 389 | + | |
| 390 | + | |
| 391 | + | |
| 392 | + | |
| 393 | + | |
| 394 | + | |
| 395 | + | |
| 396 | + | |
| 397 | + | |
| 398 | + | |
| 399 | + | |
| 400 | + | |
| 401 | + | |
| 402 | + | |
| 403 | + | |
| 404 | + | |
| 405 | + | |
| 406 | + | |
| 407 | + | |
| 408 | + | |
| 409 | + | |
| 410 | + | |
| 411 | + | |
| 412 | + | |
| 413 | + | |
| 414 | + | |
| 415 | + | |
| 416 | + | |
| 417 | + | |
| 418 | + | |
| 419 | + | |
| 420 | + | |
| 421 | + | |
| 422 | + | |
| 423 | + | |
| 424 | + | |
| 425 | + | |
| 426 | + | |
| 427 | + | |
| 428 | + | |
| 429 | + | |
| 430 | + | |
| 431 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
196 | 196 | | |
197 | 197 | | |
198 | 198 | | |
199 | | - | |
| 199 | + | |
| 200 | + | |
| 201 | + | |
| 202 | + | |
| 203 | + | |
200 | 204 | | |
201 | 205 | | |
202 | 206 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
235 | 235 | | |
236 | 236 | | |
237 | 237 | | |
238 | | - | |
| 238 | + | |
| 239 | + | |
| 240 | + | |
| 241 | + | |
| 242 | + | |
| 243 | + | |
239 | 244 | | |
240 | 245 | | |
241 | 246 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
73 | 73 | | |
74 | 74 | | |
75 | 75 | | |
76 | | - | |
| 76 | + | |
77 | 77 | | |
78 | 78 | | |
79 | 79 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
453 | 453 | | |
454 | 454 | | |
455 | 455 | | |
| 456 | + | |
| 457 | + | |
456 | 458 | | |
457 | | - | |
458 | | - | |
| 459 | + | |
| 460 | + | |
459 | 461 | | |
460 | | - | |
| 462 | + | |
461 | 463 | | |
462 | 464 | | |
463 | 465 | | |
464 | 466 | | |
465 | | - | |
466 | | - | |
| 467 | + | |
| 468 | + | |
467 | 469 | | |
468 | | - | |
| 470 | + | |
469 | 471 | | |
470 | 472 | | |
471 | 473 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
47 | 47 | | |
48 | 48 | | |
49 | 49 | | |
50 | | - | |
| 50 | + | |
51 | 51 | | |
52 | 52 | | |
53 | 53 | | |
| |||
153 | 153 | | |
154 | 154 | | |
155 | 155 | | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
| 168 | + | |
| 169 | + | |
| 170 | + | |
| 171 | + | |
| 172 | + | |
| 173 | + | |
| 174 | + | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
| 179 | + | |
| 180 | + | |
| 181 | + | |
| 182 | + | |
| 183 | + | |
| 184 | + | |
| 185 | + | |
| 186 | + | |
| 187 | + | |
| 188 | + | |
| 189 | + | |
| 190 | + | |
| 191 | + | |
| 192 | + | |
| 193 | + | |
| 194 | + | |
| 195 | + | |
| 196 | + | |
| 197 | + | |
| 198 | + | |
| 199 | + | |
| 200 | + | |
| 201 | + | |
| 202 | + | |
| 203 | + | |
| 204 | + | |
| 205 | + | |
| 206 | + | |
| 207 | + | |
| 208 | + | |
| 209 | + | |
| 210 | + | |
| 211 | + | |
| 212 | + | |
| 213 | + | |
| 214 | + | |
| 215 | + | |
| 216 | + | |
| 217 | + | |
| 218 | + | |
| 219 | + | |
| 220 | + | |
156 | 221 | | |
157 | 222 | | |
158 | 223 | | |
| |||
5160 | 5225 | | |
5161 | 5226 | | |
5162 | 5227 | | |
| 5228 | + | |
| 5229 | + | |
| 5230 | + | |
| 5231 | + | |
| 5232 | + | |
| 5233 | + | |
| 5234 | + | |
| 5235 | + | |
| 5236 | + | |
| 5237 | + | |
| 5238 | + | |
| 5239 | + | |
5163 | 5240 | | |
5164 | 5241 | | |
5165 | 5242 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
581 | 581 | | |
582 | 582 | | |
583 | 583 | | |
584 | | - | |
| 584 | + | |
| 585 | + | |
| 586 | + | |
585 | 587 | | |
586 | 588 | | |
587 | 589 | | |
| |||
0 commit comments