Repository navigation
Commit fb0d85b
feat(spec,objectql)!: a top-level action bound to another package's object is listed on that object as served, and an unregistered target fails the boot (#24458)
Fixes #24400
Clause-②: yes (narrowing)
Dispatched by the `domain:spec` seat 2 (claim 6258620187, branch
`claude/issue-24400-bound-action-foreign-object`). Dev session
`session_016ejhoxYxma7A4FRNYqEyUL`. The engine half is the rider
declared to `domain:engine` on #6367.
## What this does
The extension refusal for `actions` told an author: declare a top-level
action with `objectName: 'TARGET'`, and `defineStack()` attaches it to
the object. For an object another package owns, that was false in two
ways (P1 below). This PR makes the route real and re-trues the sentence
to say exactly what happens.
- **Engine (`@objectstack/objectql`).** `SchemaRegistry` attaches every
code package's bound top-level action to its target object at the one
step every object read ends in, `finishServedObject`
(`packages/objectql/src/registry.ts:3472`), via `attachBoundActions`
(`:3516`). Registration order doesn't matter, because the attach runs at
read time and the cached fold is dropped when an action registers or
leaves.
- Attached by reference, deduplicated by name: an action the object
already declares keeps the object's declaration, the router's own
rung-1-before-rung-2 precedence.
- The object's own actions keep their served order; attached ones
follow, sorted among themselves by `order`.
- A disabled package's bound actions are not attached.
- **Loud refusal.** A bound action whose target no loaded package
registers fails the boot at `kernel:ready`
(`packages/objectql/src/plugin.ts:932`) with `INVALID_METADATA` (422),
naming each action, its package and the object. The ObjectQL plugin
already refuses unresolved picklists at this same point; it judges here
because every package has registered by then, so "absent" is final.
After the boot, an artifact arriving through the `manifest` service is
judged before any of it registers (`plugin.ts:761`).
- **Spec (`@objectstack/spec`).** `defineStack()`'s cross-reference
check accepts an action bound to an object outside the stack in exactly
two cases (`packages/spec/src/stack.zod.ts:4066`): an object the stack
extends through `objectExtensions` (the guidance's own context), and a
known platform object (`isPlatformProvidedObjectName`, a named list, not
the `sys_` prefix). Any other name is still refused at build. The action
stays in the top-level `actions` (documented on
`mergeActionsIntoObjects`).
- **The re-trued sentence** (`ObjectExtensionSchema` `guidance.actions`,
`packages/spec/src/data/object.zod.ts`), mirrored in
`content/docs/data-modeling/object-extensions.mdx`: declare a top-level
action with `objectName: 'TARGET'` in the same stack. `defineStack()`
accepts it because the stack extends the target and keeps it in the
top-level `actions`. The engine's schema registry attaches it to the
target object wherever the object is served, whichever package registers
first. A target no loaded package registers fails the boot.
## The premises, each read before and after
**P1: holds, and is wider than the card says.** Booted showcase (`pnpm
exec objectstack dev --fresh`), built at `origin/main` `db1f1fd20`, with
one probe action added to the app stack: `{ name: 'probe_rename_org',
objectName: 'sys_organization', type: 'url' }`.
- Before, strict `defineStack` (the shipped config): the compile is
refused. `✗ Action 'probe_rename_org' references object
'sys_organization' which is not defined in objects.` The route the
guidance prescribes was refused at build in any stack that declares an
object of its own.
- Before, `defineStack(..., { strict: false })`: the server boots. `GET
/api/v1/meta/action/probe_rename_org` answers 200 (`_packageId:
com.example.showcase`). `GET /api/v1/meta/object/sys_organization`
answers 200 with `actions` = `create_organization, update_organization,
delete_organization, set_active_organization, invite_user,
leave_organization, rename_organization, change_slug`. The probe is
absent, and nothing reports it.
- After, on this branch with the shipped strict config: the server
boots, and the same read answers those eight in the same order, followed
by `probe_rename_org`.
- Controls on the same build: the stock showcase and the stock CRM both
boot with no refusal, and their `sys_organization` `actions` are
unchanged (the eight above, same order). `showcase_task`'s actions are
unchanged too.
**P2: holds. The one meeting point is
`SchemaRegistry.finishServedObject`.** It is the tail of
`foldExtendersOntoDefinition` and of `foldObjectExtendersOnto`
(`registry.ts:3459`, `:3704`, `:3706`). Every serving door reads through
it:
- REST `GET /meta/object/NAME` is `getMetaItem` → `readMetaItem`
(`packages/metadata-protocol/src/protocol.ts`). Step 1 (stored row) and
step 2 (MetadataService) go through `foldObjectExtendersFromRegistry`
(`:9333`) → `foldObjectExtendersOnto`. Step 3 is
`registry.getItem('object')` (`:11677`) → `getObject` → `resolveObject`
(`registry.ts:3379`).
- `engine.getSchema()` is `registry.getObject`. So is the action
router's object lookup (`resolveRouteActionDeclaration`, rung 1,
`packages/runtime/src/action-execution.ts`).
- Studio reads the REST door.
- MCP's `describe_object` projects `name`, `label`, `fields` and
`enable` only (`packages/runtime/src/domains/mcp.ts:592`,
`packages/mcp/src/stdio-data-bridge.ts:703`). It serves no `actions`, so
it has nothing to attach.
- No per-door code was added. The served-read pin hits the protocol's
real `getMetaItem` on a real `ObjectQL` in both registration orders.
**P3: holds. Who may run the action is unchanged.**
- The attached entry is the registry's own standalone item, by
reference. A test asserts `toBe(registry.getItem('action', name))`. So
the router's rung 1 now finds the very declaration its rung 2 found
before, carrying its own `requiredPermissions`,
`requiresMembershipReach` and execution identity.
- Handler registration (`ObjectQL.actions`) is untouched.
- Nothing else on the object moves: the attach writes only the served
`actions` array, so the owning package's `managedBy` and object-level
write policy are as before.
- Before this PR the action was already dispatchable through rung 2.
Only its listing was missing.
## Siblings (G3), measured, not fixed here
The extension guidance also tells an author to declare a top-level hook,
or a top-level `view`, bound to the target object. Measured on this
branch's built spec with `objectExtensions: [{ extend:
'sys_organization', … }]` in a stack that has one object of its own:
- **`hooks`:** refused at build. `✗ Hook 'g3_hook' references object
'sys_organization' which is not defined in objects.` It is accepted only
in an object-less stack. ADR-0130 §1.5 records this refusal as
deliberate ("the split must follow hook ownership"), so the sentence and
the ADR disagree.
- **`listViews`:** refused at build. `✗ View[0].list references object
'sys_organization' which is not defined in objects.`
Neither is the same mechanism (one is a recorded ADR decision, the other
a different cross-reference rule plus a different serving path), so both
go to the report's out-of-scope findings for the seat.
## Changeset (G4)
`.changeset/24400-bound-action-foreign-object.md`: `minor` on
`@objectstack/spec` (a widening: `defineStack` accepts two more target
kinds for the existing `objectName`) and `minor` on
`@objectstack/objectql` (the boot refusal is a narrowing, since a stack
that booted with a dangling bound action now fails; it ships as `minor`
under the launch-window convention, with `!` in the title). `Clause-②:
yes (narrowing)` is the spelling the gate reads for a diff that widens
and narrows. The changeset carries the ADR-0087 `not-required
(no-migration-prescription)` marker and the FROM, TO and fix lines. No
new error code: the refusal answers the existing `INVALID_METADATA`.
## Tests
On head `0027ae7fe0` (after merging `origin/main` `fa26308bb0` through
`scripts/pm/os-regen-merge.sh`; no regeneration was owed, and
`check:generated` reports all 14 artifacts current):
- `packages/objectql/src/bound-action-foreign-object.test.ts` (new, 14
cases):
- the served read in both orders;
- the entry is the declaration by reference;
- own order kept, attached ones sorted;
- control object unchanged;
- a same-stack action attached once;
- a same-name declaration keeps the object's;
- a disabled package's action withdrawn and restored;
- uninstall withdraws from the cache;
- the boot refusal; a later package's target boots;
- a late artifact refused before anything of it registers, and one that
brings or names its object registers;
- the refusal message lists every action.
- `packages/spec/src/stack-bound-action-foreign-object.test.ts` (new, 6
cases): an extended target is accepted and stays top-level; a known
platform object is accepted; a non-extended foreign name is still
refused, and so is `sys_organizaton`; same-stack is merged as before;
the extension refusal names the route.
- Full suites (this package or downstream): `@objectstack/objectql` 445
files / 8368 tests passed, `@objectstack/spec` 698 files / 21273 passed
(1 todo), and downstream `@objectstack/metadata-protocol` 246 / 20655,
`@objectstack/rest` 298 / 6079, `@objectstack/runtime` 421 / 6626, all
passed. `@objectstack/spec` and `@objectstack/objectql` typecheck clean.
- Ablations, each landed by `scripts/ablation-replace.mjs` and restored
byte-identical (hash-checked):
- the attach removed from `finishServedObject`: 8 of 14 engine tests
red. The 6 still green are the control, same-stack, same-name and the
refusal cases, which don't depend on the attach.
- the `kernel:ready` throw removed: exactly the boot-refusal test red.
- the `objectExtensions` acceptance removed from `defineStack`: exactly
the extended-target test red.
## Gates
`node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` on the final head derives 116 commands, and
each was run with its exit code captured before any pipe. 114 exit 0.
`--ran` reconciles 116 derived, 114 run, 2 NOT MEASURED, 0 unrun:
- `pnpm --filter @objectstack/spec run check:skill-examples` exits 3,
PREREQUISITE NOT MET (`packages/client-react/dist` not built here).
- `pnpm check:dual-build-cjs-loads` exits 3, PREREQUISITE NOT MET (eight
packages this diff does not touch have no `dist/`).
`check:engine-split-ratio` and `check-plugin-teardown-shape --self-test`
refused on the shallow clone; both pass after `git fetch --unshallow`.
`check:dev-prereqs` passes. The full farm is CI's.
## Acceptance notes
- A stored `sys_metadata` overlay row written back from a served read
(the Studio GET → edit → PUT round-trip) carries the attached action's
copy. The attach then deduplicates by name, so the read is unchanged,
but the copy outlives the binding package's uninstall. Extenders have
the same seam, which `subtractExtenderContributions` covers for
`validations` and `indexes`. Not covered here for actions.
- The MetadataService copy bridged from `getAllObjects()` at boot
carries the attached actions. It is re-bridged when packages arrive
late, but not when a binding package is withdrawn. That is the same
staleness class as the existing extender copies
(`restoreExtendedObjectCopies`).
- Two packages binding the same action name to one object, or a foreign
bound action named like one the object already declares: the attach
keeps the first (the object's own) and does not report the other. The
engine's handler map `OBJECT:ACTION` already collides silently on the
same pair, before this PR. Reported in the os-dev report as a finding,
not fixed here.
- Commits on this branch carry no trailer pair; the PR squash-lands
under its own title and body.
---
_Generated by [Claude
Code](https://claude.ai/code/session_016ejhoxYxma7A4FRNYqEyUL)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 31ce45c commit fb0d85b
8 files changed
Lines changed: 756 additions & 12 deletions
File tree
- .changeset
- content/docs/data-modeling
- packages
- objectql/src
- spec/src
- data
| 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 | |
|---|---|---|---|
| |||
82 | 82 | | |
83 | 83 | | |
84 | 84 | | |
85 | | - | |
| 85 | + | |
86 | 86 | | |
87 | 87 | | |
88 | 88 | | |
| |||
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
46 | 46 | | |
47 | 47 | | |
48 | 48 | | |
49 | | - | |
| 49 | + | |
50 | 50 | | |
51 | 51 | | |
52 | 52 | | |
| |||
393 | 393 | | |
394 | 394 | | |
395 | 395 | | |
| 396 | + | |
| 397 | + | |
| 398 | + | |
| 399 | + | |
| 400 | + | |
| 401 | + | |
| 402 | + | |
396 | 403 | | |
397 | 404 | | |
398 | 405 | | |
| |||
747 | 754 | | |
748 | 755 | | |
749 | 756 | | |
| 757 | + | |
| 758 | + | |
| 759 | + | |
| 760 | + | |
| 761 | + | |
| 762 | + | |
| 763 | + | |
| 764 | + | |
| 765 | + | |
| 766 | + | |
| 767 | + | |
| 768 | + | |
| 769 | + | |
750 | 770 | | |
751 | 771 | | |
752 | 772 | | |
| |||
903 | 923 | | |
904 | 924 | | |
905 | 925 | | |
| 926 | + | |
| 927 | + | |
| 928 | + | |
| 929 | + | |
| 930 | + | |
| 931 | + | |
| 932 | + | |
| 933 | + | |
| 934 | + | |
906 | 935 | | |
907 | 936 | | |
908 | 937 | | |
| |||
0 commit comments