Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .changeset/24400-bound-action-foreign-object.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
'@objectstack/spec': minor
'@objectstack/objectql': minor
---

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

Clause-②: yes (narrowing)

<!-- 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). -->

**BREAKING** (one narrowing beside a widening), shipped as `minor` under the launch-window convention for breaking changes.

- **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.
- **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`.
- **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.

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.
2 changes: 1 addition & 1 deletion content/docs/data-modeling/object-extensions.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ each one by name with the alternative to use instead:

| Key you might reach for | Declare this instead |
|:---|:---|
| `actions` | A top-level action with `objectName: '<target>'` — `defineStack()` attaches it to the object |
| `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 |
| `hooks` | A top-level hook bound to the target object |
| `listViews` | A top-level `view` bound to the target object |
| `fieldGroups` | Add the fields here and declare the groups on the owning object, or assign a Page for the layout |
Expand Down
353 changes: 353 additions & 0 deletions packages/objectql/src/bound-action-foreign-object.test.ts

Large diffs are not rendered by default.

31 changes: 30 additions & 1 deletion packages/objectql/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ import {
import { SecurityCatalogNameConflictError, findEnvironmentHeldSecurityCatalogNames } from './registry.js';
// [ADR-0029 D3] The registry's own refusal of a second owner — read, never
// re-derived, when a residual object's name is already owned.
import { ObjectOwnershipConflictError } from './registry.js';
import { ObjectOwnershipConflictError, collectManifestBoundActions, collectManifestObjectNames, describeUnboundActionTargets } from './registry.js';

export type { Plugin, PluginContext };

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

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