Skip to content

Commit fb0d85b

Browse files
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

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/objectql': minor
4+
---
5+
6+
feat(spec,objectql)!: a top-level action bound by `objectName` to an object another package owns is listed on that object wherever it is served, and a bound action whose object no loaded package registers fails the boot
7+
8+
Clause-②: yes (narrowing)
9+
10+
<!-- adr-0087: not-required (no-migration-prescription) No spec key, option, error code or stored shape is added, removed or renamed: `defineStack` accepts two more target kinds for an action's existing `objectName`, the served object gains entries in its existing `actions` array, and the boot refusal answers the existing `INVALID_METADATA` code. Nothing stored changes shape, so `objectstack migrate meta` has nothing to rewrite. The other categories are closed on facts: both packages publish (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); no Zod schema shape in packages/spec changes (not runtime-interface-only or type-surface-only); and nothing exported is retired (not runtime-export-retired). -->
11+
12+
**BREAKING** (one narrowing beside a widening), shipped as `minor` under the launch-window convention for breaking changes.
13+
14+
- **Widening** (`@objectstack/spec`, `@objectstack/objectql`): an action declared in a stack's top-level `actions` with `objectName` naming an object that another package owns now reaches that object. `defineStack()` accepts the action when the stack extends the object through `objectExtensions`, or when the object is a known platform object (`sys_organization` and the rest of the platform's named list). It keeps the action in the top-level `actions`. The engine's schema registry then attaches it to the object at the one step every object read ends in. That covers `GET /api/v1/meta/object/<name>` (stored-row, MetadataService and registry layers alike), `engine.getSchema()` and the action router's object lookup, in whichever order the two packages register. The attached entry is the action's own declaration, so its `requiredPermissions`, `requiresMembershipReach` and execution identity are unchanged. An action the object already declares under the same name keeps the object's declaration. A disabled package's bound actions are not attached. Before this, such an action was refused by `defineStack()` whenever the stack declared an object of its own. Where it got through (an object-less stack, or `strict: false`), it registered but was listed on no object, so no client rendered it.
15+
- **FROM** (`@objectstack/objectql`): a top-level action bound by `objectName` to an object no loaded package registers was registered and listed nowhere, and the boot succeeded. **TO:** the boot fails at `kernel:ready` with `INVALID_METADATA` (422), naming each such action, its package and the object. An artifact registered through the `manifest` service after the boot is refused before any of it registers. **Fix:** register the object (in the package or in one it depends on), or correct the action's `objectName`.
16+
- **Unchanged:** a name `defineStack()` cannot attribute (an object this stack neither defines nor extends, and not a known platform object) is still refused at build with `STACK_CROSS_REFERENCE_INVALID`. A same-stack bound action is still merged into its object by `defineStack()` and is attached once.
17+
18+
The `ObjectExtensionSchema` refusal for `actions` now says exactly this: declare a top-level action with `objectName: '<target>'` in the same stack; the schema registry attaches it to the object wherever the object is served; a target no loaded package registers fails the boot.

‎content/docs/data-modeling/object-extensions.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ each one by name with the alternative to use instead:
8282

8383
| Key you might reach for | Declare this instead |
8484
|:---|:---|
85-
| `actions` | A top-level action with `objectName: '<target>'` — `defineStack()` attaches it to the object |
85+
| `actions` | A top-level action with `objectName: '<target>'` in the same stack — it stays in the stack's top-level `actions`, and 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 |
8686
| `hooks` | A top-level hook bound to the target object |
8787
| `listViews` | A top-level `view` bound to the target object |
8888
| `fieldGroups` | Add the fields here and declare the groups on the owning object, or assign a Page for the layout |

‎packages/objectql/src/bound-action-foreign-object.test.ts‎

Lines changed: 353 additions & 0 deletions
Large diffs are not rendered by default.

‎packages/objectql/src/plugin.ts‎

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ import {
4646
import { SecurityCatalogNameConflictError, findEnvironmentHeldSecurityCatalogNames } from './registry.js';
4747
// [ADR-0029 D3] The registry's own refusal of a second owner — read, never
4848
// re-derived, when a residual object's name is already owned.
49-
import { ObjectOwnershipConflictError } from './registry.js';
49+
import { ObjectOwnershipConflictError, collectManifestBoundActions, collectManifestObjectNames, describeUnboundActionTargets } from './registry.js';
5050

5151
export type { Plugin, PluginContext };
5252

@@ -393,6 +393,13 @@ export class ObjectQLPlugin implements Plugin {
393393
* seal the vocabulary, then judge).
394394
*/
395395
private picklistVocabularySealed = false;
396+
/**
397+
* [#24400] Set at `kernel:ready` beside {@link picklistVocabularySealed},
398+
* once the boot audit has found the target object of every bound top-level
399+
* action: from then on a late artifact's bound actions are judged before it
400+
* registers.
401+
*/
402+
private boundActionTargetsSealed = false;
396403
/** Unsubscribe handles for metadata-event subscriptions (ADR-0008 PR-7). */
397404
private metadataUnsubscribes: Array<() => void> = [];
398405
/** ADR-0057 lifecycle enforcement (Reaper/Rotator/Archiver). */
@@ -747,6 +754,19 @@ export class ObjectQLPlugin implements Plugin {
747754
const refusal = describeUnresolvedPicklistReferences(unresolved, orphans);
748755
if (refusal) throw refusal;
749756
}
757+
// [#24400] The same seal for bound top-level actions: once the boot has
758+
// judged every target (`kernel:ready`, below), an artifact arriving
759+
// later must bind its actions to an object the registry holds or one
760+
// the artifact itself declares. A refused install registers nothing.
761+
if (this.boundActionTargetsSealed) {
762+
const declared = new Set<string>(residual?.objects.map((o) => o.name) ?? []);
763+
for (const manifest of ordered) for (const name of collectManifestObjectNames(manifest)) declared.add(name);
764+
const unbound = ordered
765+
.flatMap((manifest) => collectManifestBoundActions(manifest, artifactPackageId(manifest)))
766+
.filter((bound) => !declared.has(bound.objectName) && ql.registry.getObject(bound.objectName) === undefined);
767+
const refusal = describeUnboundActionTargets(unbound);
768+
if (refusal) throw refusal;
769+
}
750770

751771
for (const manifest of ordered) {
752772
ql.registerApp(manifest, scope, options?.source);
@@ -903,6 +923,15 @@ export class ObjectQLPlugin implements Plugin {
903923
);
904924
if (unresolvedPicklists) throw unresolvedPicklists;
905925
this.picklistVocabularySealed = true;
926+
// [#24400] A top-level action bound by `objectName` to an object no
927+
// package registers fails the boot, naming the action, its package and
928+
// the object — never registered and listed nowhere. Judged here for the
929+
// picklist reason: every package has registered, so "no such object"
930+
// is final, and either registration order has had its turn. From here
931+
// on each late artifact is judged as it arrives (the `manifest` service).
932+
const unboundActions = describeUnboundActionTargets(this.ql?.registry.findUnboundActionTargets() ?? []);
933+
if (unboundActions) throw unboundActions;
934+
this.boundActionTargetsSealed = true;
906935
// #7737 — FIRST, before anything that might read data: bind every
907936
// declared federated object to its remote table now that every
908937
// plugin's `start()` (including the declared-datasource auto-connect

0 commit comments

Comments
 (0)