From d796a9204b5625c42d5a7169ded72555c109681a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 03:12:32 +0000 Subject: [PATCH 1/3] wip(objectql,spec): attach bound top-level actions to another package's object at the registry fold --- .../docs/data-modeling/object-extensions.mdx | 2 +- packages/objectql/src/plugin.ts | 31 ++- packages/objectql/src/registry.ts | 232 +++++++++++++++++- packages/spec/src/data/object.zod.ts | 7 +- packages/spec/src/stack.zod.ts | 36 ++- 5 files changed, 296 insertions(+), 12 deletions(-) diff --git a/content/docs/data-modeling/object-extensions.mdx b/content/docs/data-modeling/object-extensions.mdx index e19079fcba6..5c35644d1b7 100644 --- a/content/docs/data-modeling/object-extensions.mdx +++ b/content/docs/data-modeling/object-extensions.mdx @@ -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: ''` — `defineStack()` attaches it to the object | +| `actions` | A top-level action with `objectName: ''` 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 | diff --git a/packages/objectql/src/plugin.ts b/packages/objectql/src/plugin.ts index 8fbce83c76a..e1e9b3efcec 100644 --- a/packages/objectql/src/plugin.ts +++ b/packages/objectql/src/plugin.ts @@ -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 }; @@ -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). */ @@ -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(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); @@ -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 diff --git a/packages/objectql/src/registry.ts b/packages/objectql/src/registry.ts index 973bbb2f253..c0897f845b0 100644 --- a/packages/objectql/src/registry.ts +++ b/packages/objectql/src/registry.ts @@ -101,6 +101,108 @@ import { type PicklistOptionLike, type PicklistReference, } from './picklist-resolution.js'; +import { standaloneActionOwnerKey, isObjectLessActionKey } from './action-governance.js'; + +/** + * [#24400] A bound top-level action whose target object no package has + * registered — see {@link SchemaRegistry.findUnboundActionTargets}. + */ +export interface UnboundActionTarget { + /** The action's `name`. */ + action: string; + /** The object its `objectName` names. */ + objectName: string; + /** The package that ships the action. */ + packageId: string | undefined; +} + +/** The boot refusal's error: the `INVALID_METADATA` envelope (ADR-0112) the picklist refusal also answers. */ +export interface UnboundActionTargetError extends Error { + code: 'INVALID_METADATA'; + status: 422; + httpStatus: 422; +} + +/** + * [#24400] The refusal for bound top-level actions whose target object no + * loaded package registers — every one of them, so an author fixes the set in + * one pass. `undefined` when there are none. + * + * Refused rather than dropped: such an action registers, is listed on no + * object, and no client renders it — the silent drop the extension guidance + * would otherwise lead an author into. + */ +export function describeUnboundActionTargets( + unbound: readonly UnboundActionTarget[], +): UnboundActionTargetError | undefined { + if (unbound.length === 0) return undefined; + const lines = unbound.map( + (u) => `action '${u.action}' (${u.packageId ? `package '${u.packageId}'` : 'a registration with no package'}) ` + + `is bound to object '${u.objectName}'`, + ); + const err = new Error( + `${lines.length === 1 ? 'A top-level action is' : `${lines.length} top-level actions are`} bound by ` + + `\`objectName\` to an object that no loaded package registers: ${lines.join('; ')}. A bound action ` + + 'is attached to its object when the object is served, so with no such object it would be listed ' + + 'nowhere. Register the object (in the package or in one it depends on), or correct the ' + + '`objectName`.', + ) as UnboundActionTargetError; + err.code = 'INVALID_METADATA'; + err.status = 422; + err.httpStatus = 422; + return err; +} + +/** A manifest and its nested `plugins[]`, which register under its package (`registerPlugin`). */ +function manifestAndNestedPlugins(manifest: any): any[] { + const plugins = Array.isArray(manifest?.plugins) + ? manifest.plugins.filter((p: unknown) => p !== null && typeof p === 'object') + : []; + return [manifest, ...plugins]; +} + +/** + * [#24400] The bound top-level actions one manifest brings — its own + * `actions` and its nested plugins', each with the object it is bound to — + * read BEFORE the manifest registers, by the post-boot install door. + */ +export function collectManifestBoundActions(manifest: any, packageId: string | undefined): UnboundActionTarget[] { + const out: UnboundActionTarget[] = []; + for (const source of manifestAndNestedPlugins(manifest)) { + for (const action of Array.isArray(source?.actions) ? source.actions : []) { + if (!action || typeof action !== 'object' || typeof action.name !== 'string') continue; + const objectName = standaloneActionOwnerKey(action); + if (isObjectLessActionKey(objectName)) continue; + out.push({ action: action.name, objectName, packageId }); + } + } + return out; +} + +/** [#24400] The object names one manifest declares, nested plugins included, in either authored spelling. */ +export function collectManifestObjectNames(manifest: any): string[] { + const names: string[] = []; + for (const source of manifestAndNestedPlugins(manifest)) { + const objects = source?.objects; + if (Array.isArray(objects)) { + for (const o of objects) if (typeof o?.name === 'string') names.push(o.name); + } else if (objects && typeof objects === 'object') { + names.push(...Object.keys(objects)); + } + } + return names; +} + +/** + * Stable sort by `order` (unset reads as `0`), by reference when no action + * sets one — the rule `defineStack()` applies to each object's merged actions + * (`sortActionsByOrder` in `@objectstack/spec`'s stack builder, which is + * internal there), so an attached action sorts exactly as a same-stack one. + */ +function sortActionsByOrder(actions: T[]): T[] { + if (!actions.some((a) => a?.order !== undefined)) return actions; + return actions.slice().sort((a, b) => (a?.order ?? 0) - (b?.order ?? 0)); +} /** * Reserved namespaces that do not get FQN prefix applied. @@ -3354,7 +3456,113 @@ export class SchemaRegistry { // A picklist-bound field is served with its list's options resolved onto // it — here, in the fold every object read and the write door share, so // the set a client is offered and the set a write is judged by are one. - return this.resolvePicklistFields(merged); + return this.finishServedObject(merged); + } + + /** + * The tail every object read ends in — {@link resolveObject} (through + * {@link foldExtendersOntoDefinition}), the code layer, and + * {@link foldObjectExtendersOnto}, which the protocol's stored-row and + * MetadataService layers run. A step added here reaches every one of them. + * + * 1. picklist-bound fields get their list's options resolved onto them; + * 2. [#24400] the code packages' bound top-level actions are attached + * ({@link attachBoundActions}). + */ + private finishServedObject(body: T): T { + return this.attachBoundActions(this.resolvePicklistFields(body)); + } + + /** + * [#24400] Attach to an object the top-level actions that code packages + * bind to it by `objectName` — whichever package registered them, and in + * either registration order. + * + * `defineStack()` merges a bound action into its target only when the target + * is in the SAME stack (`mergeActionsIntoObjects`). An action bound to an + * object another package owns — the route `ObjectExtensionSchema`'s + * `guidance.actions` names, since an extension cannot carry `actions` — stays + * at the stack's top level and registers here as a standalone `action` item. + * The router already dispatched it (`resolveRouteActionDeclaration`, rung 2), + * but no served object listed it, so no client rendered it. Attaching it + * HERE, at read time, is what makes it order-independent: the object and + * the action may register in either order, and the cached fold is dropped + * when an action registers ({@link registerItem}) or leaves. + * + * What attaches: a standalone `action` item a CODE package shipped + * (`isCodeArtifactBody` — never a tenant-authored row), whose owner key + * ({@link standaloneActionOwnerKey}, the router's own test) is this object's + * name, and whose package is not disabled. Attached BY REFERENCE, so the + * served entry is the very declaration rung 2 resolves: its + * `requiredPermissions`, `requiresMembershipReach` and execution identity + * travel with it unchanged, and nothing about the object's own write policy + * moves. + * + * Deduplicated by NAME, which is also what makes the step idempotent over a + * body that already carries the action — the same-stack merge's copy, a + * MetadataService copy bridged from {@link getAllObjects}, or a stored row + * written back from a served read. An action the object already declares + * keeps the object's declaration, the precedence the router applies (rung 1 + * before rung 2). A target no package registers is refused at boot, not + * here — see {@link findUnboundActionTargets}. + * + * Returns `body` by reference when nothing attaches. + */ + private attachBoundActions(body: T): T { + if (body === null || typeof body !== 'object') return body; + const objectName = (body as { name?: unknown }).name; + if (typeof objectName !== 'string' || objectName === '') return body; + const collection = this.metadata.get('action'); + if (!collection || collection.size === 0) return body; + const declared = (body as { actions?: unknown }).actions; + const base: any[] = Array.isArray(declared) ? declared : []; + const present = new Set(base.map((action) => action?.name)); + const fresh: any[] = []; + for (const action of this.boundCodeActions()) { + if (standaloneActionOwnerKey(action) !== objectName || present.has(action.name)) continue; + present.add(action.name); + fresh.push(action); + } + if (fresh.length === 0) return body; + return { ...body, actions: sortActionsByOrder([...base, ...fresh]) }; + } + + /** + * [#24400] The standalone `action` items code packages ship that are bound + * to an object — the population {@link attachBoundActions} attaches and + * {@link findUnboundActionTargets} audits. A disabled package's actions are + * left out, as the rest of its items are withheld. + */ + private boundCodeActions(): any[] { + const out: any[] = []; + for (const action of this.metadata.get('action')?.values() ?? []) { + if (!action || typeof action !== 'object' || typeof (action as any).name !== 'string') continue; + if (!isCodeArtifactBody(action)) continue; + if (isObjectLessActionKey(standaloneActionOwnerKey(action))) continue; + if (this.isPackageDisabled((action as any)._packageId)) continue; + out.push(action); + } + return out; + } + + /** + * [#24400] The bound top-level actions whose target object NO package has + * registered — each one an action no served object will ever list, so it is + * refused at boot rather than dropped ({@link describeUnboundActionTargets}). + * + * Only meaningful once every package has registered: the ObjectQL plugin + * asks at `kernel:ready` (AGENTS.md, startup registry reads — a target a + * later package registers would otherwise read as missing), and judges each + * later artifact as it arrives. + */ + findUnboundActionTargets(): UnboundActionTarget[] { + const out: UnboundActionTarget[] = []; + for (const action of this.boundCodeActions()) { + const objectName = standaloneActionOwnerKey(action); + if (this.resolveObjectKey(objectName) !== undefined) continue; + out.push({ action: action.name, objectName, packageId: action._packageId }); + } + return out; } /** @@ -3486,9 +3694,9 @@ export class SchemaRegistry { // resolved — the same step the fold below ends with — so a body this // registry never saw is served in the one shape every other read uses. // By reference when no field names a picklist. - if (fqn === undefined) return this.resolvePicklistFields(base); + if (fqn === undefined) return this.finishServedObject(base); const contributors = this.objectContributors.get(fqn); - if (!contributors || !contributors.some((c) => c.ownership === 'extend')) return this.resolvePicklistFields(base); + if (!contributors || !contributors.some((c) => c.ownership === 'extend')) return this.finishServedObject(base); return this.foldExtendersOntoDefinition( contributors, this.subtractExtenderContributions(contributors, base as unknown as ServiceObject), @@ -4874,6 +5082,12 @@ export class SchemaRegistry { collection.set(storageKey, item); if (type === 'picklist') this.invalidateAll(); + // [#24400] A bound action is attached to its target on read + // ({@link attachBoundActions}); drop that object's cached fold. + if (type === 'action') { + const target = standaloneActionOwnerKey(item); + if (!isObjectLessActionKey(target)) this.invalidate(target); + } this.log(`[Registry] Registered ${type}: ${storageKey}`); } @@ -4902,7 +5116,8 @@ export class SchemaRegistry { unregisterItem(type: string, name: string) { // Every resolved object may carry this list's options; the removal below // is synchronous, so the next fold re-resolves against the store without it. - if (type === 'picklist') this.invalidateAll(); + // [#24400] …and an object may carry this bound action ({@link attachBoundActions}). + if (type === 'picklist' || type === 'action') this.invalidateAll(); const collection = this.metadata.get(type); if (!collection) { console.warn(`[Registry] Attempted to unregister non-existent ${type}: ${name}`); @@ -5063,7 +5278,8 @@ export class SchemaRegistry { // The package's `picklistExtensions` leave with it, and every resolved // object is re-derived without its lists and its added values. const droppedExtensions = this.unregisterPicklistExtensionsByPackage(packageId); - if (droppedExtensions || removed.some((r) => r.startsWith('picklist/'))) this.invalidateAll(); + // [#24400] …and so is every object its bound actions were attached to. + if (droppedExtensions || removed.some((r) => r.startsWith('picklist/') || r.startsWith('action/'))) this.invalidateAll(); return { removed, orphanedOverlays }; } @@ -5772,6 +5988,9 @@ export class SchemaRegistry { pkg.statusChangedAt = new Date().toISOString(); pkg.updatedAt = new Date().toISOString(); this.log(`[Registry] Enabled package: ${id}`); + // [#24400] A disabled package's bound actions are not attached + // ({@link boundCodeActions}), so every object's fold is re-derived. + this.invalidateAll(); } return pkg; } @@ -5784,6 +6003,9 @@ export class SchemaRegistry { pkg.statusChangedAt = new Date().toISOString(); pkg.updatedAt = new Date().toISOString(); this.log(`[Registry] Disabled package: ${id}`); + // [#24400] A disabled package's bound actions are not attached + // ({@link boundCodeActions}), so every object's fold is re-derived. + this.invalidateAll(); } return pkg; } diff --git a/packages/spec/src/data/object.zod.ts b/packages/spec/src/data/object.zod.ts index edb6930a733..367602415ad 100644 --- a/packages/spec/src/data/object.zod.ts +++ b/packages/spec/src/data/object.zod.ts @@ -3983,8 +3983,11 @@ export const ObjectExtensionSchema = lazySchema(() => strictObject({ actions: '`actions` cannot be contributed through an object extension — the merge carries ' + '`fields`, `label`, `pluralLabel`, `description`, `validations` and `indexes` only. ' + - "Declare a top-level action with `objectName: ''`; `defineStack()` attaches " + - 'it to the object.', + "Declare a top-level action with `objectName: ''` in the same stack: " + + '`defineStack()` accepts it because the stack extends the target, keeps it in the ' + + '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: '`hooks` cannot be contributed through an object extension — declare a top-level ' + 'hook bound to the target object instead.', diff --git a/packages/spec/src/stack.zod.ts b/packages/spec/src/stack.zod.ts index a35d4fb7651..b80de7677e0 100644 --- a/packages/spec/src/stack.zod.ts +++ b/packages/spec/src/stack.zod.ts @@ -18,7 +18,7 @@ import { StackServerConfigSchema } from './system/stack-server.zod'; import { StackEmailConfigSchema } from './system/email-config.zod'; import { StackSmsConfigSchema } from './system/sms-config.zod'; import { DevLoginSchema } from './system/dev-login.zod'; -import { hasPlatformObjectPrefix } from './system/constants/platform-object-names'; +import { hasPlatformObjectPrefix, isPlatformProvidedObjectName } from './system/constants/platform-object-names'; import { objectStackErrorMap, formatZodError } from './shared/error-map.zod'; import { strictObject } from './shared/strict-object'; import { deepEqualAuthored } from './shared/deep-equal'; @@ -4027,6 +4027,11 @@ function validateCrossReferences( // `retiredKey()` tombstone on `ListViewSchema`), so there is no reference // left to resolve rather than a resolution left unchecked. + const extendedObjectNames = new Set( + (config.objectExtensions ?? []) + .map((ext) => (isRecord(ext) ? ext.extend : undefined)) + .filter((name): name is string => typeof name === 'string' && name.length > 0), + ); if (config.actions) { for (const action of config.actions) { // Validate flow-type actions reference a defined flow @@ -4043,8 +4048,24 @@ function validateCrossReferences( ); } - // Validate action → object references (objectName) - if (action.objectName && !objectNames.has(action.objectName)) { + // Validate action → object references (objectName). [#24400] Two kinds + // of target outside this stack's `objects` are accepted, because this + // stack's own metadata says the object exists elsewhere: one it EXTENDS + // (`objectExtensions` — the route that shape's `guidance.actions` names, + // since an extension cannot carry `actions`), and a KNOWN platform + // object (`isPlatformProvidedObjectName`, a named list — not the + // prefix, so `sys_organizaton` is still refused here). Such an action + // stays at the top level ({@link mergeActionsIntoObjects}), and the + // engine's registry attaches it to the object when the object is served; + // a target no loaded package registers is refused at boot. Any other + // name is refused here: this stack cannot tell another package's object + // from a typo. + if ( + action.objectName && + !objectNames.has(action.objectName) && + !extendedObjectNames.has(action.objectName) && + !isPlatformProvidedObjectName(action.objectName) + ) { errors.push( `Action '${action.name}' references object '${action.objectName}' which is not defined in objects.`, ); @@ -4174,6 +4195,15 @@ function sortActionsByOrder(actions: T[]): T[] { * top-level `actions` array is preserved for global access (e.g., platform * overview, search). * + * [#24400] An action whose `objectName` names an object NOT in this stack — one + * this stack extends through `objectExtensions`, or a platform object, the two + * cases `validateCrossReferences` accepts — is merged into nothing here, + * because this merge cannot see another package's objects. It stays in the + * top-level `actions`, registers as a standalone `action` item, and the + * engine's `SchemaRegistry` attaches it to the target when the target is + * served (`attachBoundActions`, in whichever order the two register). A + * target no loaded package registers fails the boot, naming the action. + * * Idempotent over its own output (#14847): a bound action the object already * carries BY IDENTITY is not appended again. That is the shape `composeStacks` * feeds it — inputs built by `defineStack`, each object already carrying the From df4c867387801fce9ea12a909095736acfcf8dec Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 03:15:54 +0000 Subject: [PATCH 2/3] wip: tests + changeset --- .../24400-bound-action-foreign-object.md | 18 + .../src/bound-action-foreign-object.test.ts | 339 ++++++++++++++++++ .../stack-bound-action-foreign-object.test.ts | 82 +++++ 3 files changed, 439 insertions(+) create mode 100644 .changeset/24400-bound-action-foreign-object.md create mode 100644 packages/objectql/src/bound-action-foreign-object.test.ts create mode 100644 packages/spec/src/stack-bound-action-foreign-object.test.ts diff --git a/.changeset/24400-bound-action-foreign-object.md b/.changeset/24400-bound-action-foreign-object.md new file mode 100644 index 00000000000..669f9a7972a --- /dev/null +++ b/.changeset/24400-bound-action-foreign-object.md @@ -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) + + + +**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/` (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: ''` 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. diff --git a/packages/objectql/src/bound-action-foreign-object.test.ts b/packages/objectql/src/bound-action-foreign-object.test.ts new file mode 100644 index 00000000000..252af65e926 --- /dev/null +++ b/packages/objectql/src/bound-action-foreign-object.test.ts @@ -0,0 +1,339 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#24400] A top-level action bound by `objectName` to an object ANOTHER package + * owns reaches that object as it is served — `GET /api/v1/meta/object/`'s + * `actions` (the protocol's by-name read) and the registry's own resolved + * object — whichever of the two packages registers first. A bound action whose + * target no loaded package registers fails the boot instead of being listed + * nowhere. + * + * This is the route `ObjectExtensionSchema`'s `guidance.actions` names, since + * an object extension cannot carry `actions`. Before this, `defineStack()` + * merged a bound action only into an object of the SAME stack, and the action + * for another package's object registered as a standalone item that no served + * object listed (measured on a booted showcase: present at + * `GET /meta/action/`, absent from `GET /meta/object/sys_organization`). + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import { ObjectKernel, type PluginContext } from '@objectstack/core'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { defineStack } from '@objectstack/spec'; +import { ObjectQL } from './engine.js'; +import { ObjectQLPlugin } from './plugin.js'; +import { describeUnboundActionTargets } from './registry.js'; +// [#21516] The rest of the stored-metadata family the repository writes through: the +// engine refuses a name the registry does not resolve, so the harness registers it as a boot does. +import { SysMetadataAuditObject, SysMetadataCommitObject, SysMetadataHistoryObject } from '@objectstack/metadata-core'; + +const sysMetadataObject = { + name: 'sys_metadata', + label: 'System Metadata', + fields: { + id: { name: 'id', label: 'ID', type: 'text' as const, primaryKey: true }, + type: { name: 'type', label: 'Type', type: 'text' as const, required: true }, + name: { name: 'name', label: 'Name', type: 'text' as const, required: true }, + organization_id: { name: 'organization_id', label: 'Org', type: 'text' as const }, + // [#8682] The real `sys_metadata` carries this — it is part of the + // row's uniqueness key `(type, name, organization_id, package_id)` and + // `SysMetadataRepository` writes it — but this minimal stub had omitted + // it. Nothing noticed while an undeclared write key simply travelled to + // the driver; the declared-field door judges the payload against this + // map, so the omission now shows up as the fixture defect it always was. + package_id: { name: 'package_id', label: 'Package', type: 'text' as const }, + metadata: { name: 'metadata', label: 'Body', type: 'textarea' as const }, + checksum: { name: 'checksum', label: 'Checksum', type: 'text' as const, maxLength: 71 }, + state: { name: 'state', label: 'State', type: 'text' as const }, + version: { name: 'version', label: 'Version', type: 'number' as const }, + created_at: { name: 'created_at', label: 'Created', type: 'datetime' as const }, + updated_at: { name: 'updated_at', label: 'Updated', type: 'datetime' as const }, + }, +}; + +/** + * Minimal stub driver covering only what `SysMetadataRepository` + * exercises. Equality-only WHERE evaluation; one record store per object. + */ +function makeStubDriver() { + const stores = new Map>>(); + const storeFor = (obj: string) => { + let s = stores.get(obj); + if (!s) { s = new Map(); stores.set(obj, s); } + return s; + }; + let nextId = 0; + + // `$and` / `$or` are conjoined WITH their sibling keys, the way a real + // driver ANDs them. The short-circuiting shape this stub used to carry + // (`if ($or) return $or.some(...)`) discarded every sibling equality key in + // the same object, so a query like + // `{ state:'draft', package_id, $or:[{organization_id:ORG},{organization_id:null}] }` + // was silently answered on the `$or` alone — a different query than the one + // written, with the suite still green. See #7620. + const matchesWhere = (row: Record, where: any): boolean => { + if (!where || typeof where !== 'object') return true; + for (const [k, v] of Object.entries(where)) { + if (k === '$and' && Array.isArray(v)) { + if (!v.every((w: any) => matchesWhere(row, w))) return false; + continue; + } + if (k === '$or' && Array.isArray(v)) { + if (!v.some((w: any) => matchesWhere(row, w))) return false; + continue; + } + if (k.startsWith('$')) continue; + const rowVal = row[k]; + const expected = (v && typeof v === 'object' && '$eq' in (v as any)) + ? (v as any).$eq + : v; + const a = rowVal === undefined ? null : rowVal; + const b = expected === undefined ? null : expected; + if (a !== b) return false; + } + return true; + }; + + const driver: any = { + name: 'memory', + version: '0.0.0', + supports: {} as any, + async connect() {}, + async disconnect() {}, + async checkHealth() { return true; }, + async execute() { return null; }, + async find(object: string, ast: any) { + const rows = Array.from(storeFor(object).values()).filter((r) => matchesWhere(r, ast?.where)); + return typeof ast?.limit === 'number' ? rows.slice(0, ast.limit) : rows; + }, + async findOne(object: string, ast: any) { + for (const r of storeFor(object).values()) if (matchesWhere(r, ast?.where)) return r; + return null; + }, + async create(object: string, data: Record) { + nextId += 1; + const id = (data.id as string) ?? `r_${nextId}`; + const row = { ...data, id }; + storeFor(object).set(id, row); + return row; + }, + async update(object: string, id: string, data: Record) { + const s = storeFor(object); + const cur = s.get(id); + if (!cur) throw new Error(`not found: ${object}/${id}`); + const updated = { ...cur, ...data, id }; + s.set(id, updated); + return updated; + }, + async upsert(object: string, data: Record) { + const id = data.id as string | undefined; + if (id && storeFor(object).has(id)) return this.update(object, id, data); + return this.create(object, data); + }, + async delete(object: string, id: string) { + return storeFor(object).delete(id); + }, + async count(object: string, ast: any) { + return (await this.find(object, ast)).length; + }, + async bulkCreate(object: string, rows: Record[]) { + return Promise.all(rows.map((r) => this.create(object, r))); + }, + async bulkUpdate() { return []; }, + async bulkDelete() {}, + async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; }, + async commit() {}, + async rollback() {}, + }; + return { driver, stores }; +} + + + +/** The package that OWNS the target object — it knows nothing of the action. */ +const OWNER = { + id: 'com.test.owner', name: 'owner', + objects: [{ + name: 'ba_org', label: 'Org', + fields: { name: { name: 'name', type: 'text', label: 'Name' } }, + actions: [{ name: 'own_action', label: 'Own', type: 'url', target: 'https://example.com/own', locations: ['record_header'] }], + }], +}; + +/** The bound action another package declares for the owner's object. */ +const BOUND = { + name: 'rename_org', label: 'Rename', objectName: 'ba_org', type: 'url', + target: 'https://example.com/rename', locations: ['record_header'], + requiredPermissions: ['org_admin'], +}; + +/** The package that binds an action to the owner's object — and owns nothing of its own. */ +const BINDER = { id: 'com.test.binder', name: 'binder', actions: [BOUND] }; + +/** A second object nothing binds to — the control. */ +const CONTROL = { + id: 'com.test.control', name: 'control', + objects: [{ name: 'ba_control', label: 'Control', fields: { name: { name: 'name', type: 'text', label: 'Name' } } }], +}; + +const actionNames = (body: any): string[] => (Array.isArray(body?.actions) ? body.actions.map((a: any) => a?.name) : []); + +async function hostWith(packages: any[]) { + const engine = new ObjectQL(); + engine.registerDriver(makeStubDriver().driver, true); + await engine.init(); + engine.registry.registerObject(sysMetadataObject); + for (const o of [SysMetadataHistoryObject, SysMetadataAuditObject, SysMetadataCommitObject]) engine.registry.registerObject(o as any); + for (const pkg of packages) engine.registerApp(structuredClone(pkg)); + return { engine, protocol: new ObjectStackProtocolImplementation(engine) }; +} + +describe('a bound top-level action reaches another package\'s object as served', () => { + for (const [order, packages] of [ + ['owner first, binder second', [OWNER, BINDER, CONTROL]], + ['binder first, owner second', [BINDER, OWNER, CONTROL]], + ] as const) { + it(`${order}: GET /meta/object/ lists it after the owner's own actions`, async () => { + const { engine, protocol } = await hostWith([...packages]); + const served: any = await protocol.getMetaItem({ type: 'object', name: 'ba_org' } as any); + expect(actionNames(served.item)).toEqual(['own_action', 'rename_org']); + expect(actionNames(engine.registry.getObject('ba_org'))).toEqual(['own_action', 'rename_org']); + }); + } + + it('the served entry is the declaration itself — its own gate travels with it unchanged', async () => { + const { engine } = await hostWith([OWNER, BINDER]); + const attached: any = engine.registry.getObject('ba_org')?.actions?.find((a: any) => a.name === 'rename_org'); + // By reference: the router's rung 1 (the object's actions) now finds the + // very item its rung 2 (`registry.getItem('action', name)`) found before. + expect(attached).toBe(engine.registry.getItem('action', 'rename_org')); + expect(attached).toMatchObject({ requiredPermissions: ['org_admin'], _packageId: 'com.test.binder' }); + }); + + it('control: an object nothing binds to is served as declared', async () => { + const { protocol } = await hostWith([OWNER, BINDER, CONTROL]); + const served: any = await protocol.getMetaItem({ type: 'object', name: 'ba_control' } as any); + expect(served.item.actions).toBeUndefined(); + }); + + it('a same-stack bound action is attached once, as defineStack already merged it', async () => { + const stack = defineStack({ + manifest: { id: 'com.test.same', name: 'same', version: '1.0.0', type: 'app' }, + objects: [{ name: 'ba_same', label: 'Same', fields: { name: { name: 'name', type: 'text', label: 'Name' } } }], + actions: [{ name: 'same_action', label: 'Same', objectName: 'ba_same', type: 'url', target: 'https://example.com', locations: ['record_header'] }], + } as any); + const { protocol } = await hostWith([{ ...stack, ...stack.manifest }]); + const served: any = await protocol.getMetaItem({ type: 'object', name: 'ba_same' } as any); + expect(actionNames(served.item)).toEqual(['same_action']); + }); + + it('an action the object already declares under the same name keeps the object\'s declaration', async () => { + const { engine } = await hostWith([OWNER, { id: 'com.test.twin', name: 'twin', actions: [{ ...BOUND, name: 'own_action' }] }]); + const actions: any[] = engine.registry.getObject('ba_org')?.actions ?? []; + expect(actions.map((a) => a.name)).toEqual(['own_action']); + expect(actions[0].target).toBe('https://example.com/own'); + }); + + it('a disabled package\'s bound action is withdrawn, and returns when the package is enabled', async () => { + const { engine } = await hostWith([OWNER, BINDER]); + engine.registry.disablePackage('com.test.binder'); + expect(actionNames(engine.registry.getObject('ba_org'))).toEqual(['own_action']); + engine.registry.enablePackage('com.test.binder'); + expect(actionNames(engine.registry.getObject('ba_org'))).toEqual(['own_action', 'rename_org']); + }); + + it('uninstalling the binding package withdraws the action from the cached object', async () => { + const { engine } = await hostWith([OWNER, BINDER]); + expect(actionNames(engine.registry.getObject('ba_org'))).toContain('rename_org'); + engine.registry.unregisterItemsByPackage('com.test.binder'); + expect(actionNames(engine.registry.getObject('ba_org'))).toEqual(['own_action']); + }); +}); + +// ── The boot refusal: a target no loaded package registers ───────────────── + +/** The slice of the `manifest` service these cases call. */ +interface ManifestService { + register(artifact: unknown): Promise | void; +} + +const kernels: ObjectKernel[] = []; +afterEach(async () => { + while (kernels.length) { + const kernel = kernels.pop()!; + try { if (kernel.getState() === 'running') await kernel.shutdown(); } catch { /* noop */ } + } +}); + +/** A plugin that registers each artifact through the `manifest` service during init. */ +function registering(name: string, artifacts: unknown[]) { + return { + name, + dependencies: ['com.objectstack.engine.objectql'], + init: async (ctx: PluginContext) => { + const manifest = ctx.getService('manifest'); + for (const artifact of artifacts) await manifest.register(structuredClone(artifact)); + }, + }; +} + +function kernelWith(...plugins: any[]) { + const kernel = new ObjectKernel({ logger: { level: 'silent' }, gracefulShutdown: false }); + kernels.push(kernel); + return { kernel, use: async () => { for (const p of plugins) await kernel.use(p); } }; +} + +describe('the bound-action boot audit', () => { + it('fails the boot naming the action, its package and the object', async () => { + const { kernel, use } = kernelWith(new ObjectQLPlugin(), registering('binder', [BINDER])); + await use(); + const err: any = await kernel.bootstrap().then(() => undefined, (e) => e); + expect(err).toMatchObject({ code: 'INVALID_METADATA', status: 422 }); + expect(err.message).toContain("action 'rename_org' (package 'com.test.binder') is bound to object 'ba_org'"); + }); + + it('boots when the target arrives from a package registered LATER in the same boot', async () => { + const { kernel, use } = kernelWith(new ObjectQLPlugin(), registering('binder', [BINDER]), registering('owner', [OWNER])); + await use(); + await kernel.bootstrap(); + const ql = kernel.getService('objectql'); + expect(actionNames(ql.registry.getObject('ba_org'))).toEqual(['own_action', 'rename_org']); + }); + + it('after the boot, an artifact binding to an unknown object is refused before ANY of it registers', async () => { + const { kernel, use } = kernelWith(new ObjectQLPlugin()); + await use(); + await kernel.bootstrap(); + const manifest = kernel.getService('manifest'); + expect(() => manifest.register(structuredClone(BINDER))).toThrow(/action 'rename_org' \(package 'com\.test\.binder'\) is bound to object 'ba_org'/); + const ql = kernel.getService('objectql'); + expect(ql.registry.getItem('action', 'rename_org')).toBeUndefined(); + }); + + it('after the boot, an artifact binding to a registered object — or to one it brings — registers', async () => { + const { kernel, use } = kernelWith(new ObjectQLPlugin(), registering('owner', [OWNER])); + await use(); + await kernel.bootstrap(); + const manifest = kernel.getService('manifest'); + await manifest.register(structuredClone(BINDER)); + await manifest.register({ + id: 'com.test.self', name: 'self', + objects: [{ name: 'ba_self', label: 'Self', fields: { name: { name: 'name', type: 'text', label: 'Name' } } }], + actions: [{ ...BOUND, name: 'self_action', objectName: 'ba_self' }], + }); + const ql = kernel.getService('objectql'); + expect(actionNames(ql.registry.getObject('ba_org'))).toEqual(['own_action', 'rename_org']); + expect(actionNames(ql.registry.getObject('ba_self'))).toEqual(['self_action']); + }); + + it('the refusal lists every unbound action in one pass, and answers nothing when there is none', () => { + expect(describeUnboundActionTargets([])).toBeUndefined(); + const err = describeUnboundActionTargets([ + { action: 'a', objectName: 'x', packageId: 'p' }, + { action: 'b', objectName: 'y', packageId: undefined }, + ]); + expect(err).toMatchObject({ code: 'INVALID_METADATA', status: 422 }); + expect(err?.message).toContain('2 top-level actions are bound'); + expect(err?.message).toContain("action 'b' (a registration with no package) is bound to object 'y'"); + }); +}); diff --git a/packages/spec/src/stack-bound-action-foreign-object.test.ts b/packages/spec/src/stack-bound-action-foreign-object.test.ts new file mode 100644 index 00000000000..af59baa3c10 --- /dev/null +++ b/packages/spec/src/stack-bound-action-foreign-object.test.ts @@ -0,0 +1,82 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#24400] `defineStack` accepts a top-level action bound by `objectName` to an + * object this stack does not define, in exactly the two cases its own metadata + * says the object exists elsewhere — an object it EXTENDS through + * `objectExtensions` (the route `ObjectExtensionSchema`'s `guidance.actions` + * names) and a KNOWN platform object — and keeps it in the top-level `actions`, + * merged into nothing. The engine's schema registry attaches it to the target + * when the target is served (`@objectstack/objectql`, + * `bound-action-foreign-object.test.ts`), and refuses at boot a target no + * loaded package registers. + * + * Every other name is still refused here: one stack cannot tell another + * package's object from a typo. + */ + +import { describe, it, expect } from 'vitest'; +import { defineStack } from './stack.zod'; +import { ObjectExtensionSchema } from './data/object.zod'; + +const manifest = { id: 'com.test.binder', name: 'binder', version: '1.0.0', type: 'app' as const }; +const OWN = { name: 'binder_note', label: 'Note', fields: { title: { type: 'text' as const, label: 'Title' } } }; +const bound = (objectName: string) => ({ + name: 'probe_bound', label: 'Probe', objectName, type: 'url' as const, target: 'https://example.com', + locations: ['record_header' as const], +}); + +const refusalOf = (config: unknown): any => { + try { + defineStack(config as any); + return undefined; + } catch (error) { + return error; + } +}; + +describe('defineStack and an action bound to an object outside the stack', () => { + it('accepts a target the stack extends, and keeps the action at the top level, merged into nothing', () => { + const stack = defineStack({ + manifest, + objects: [OWN], + objectExtensions: [{ extend: 'crm_account', fields: { tier: { type: 'text', label: 'Tier' } } }], + actions: [bound('crm_account')], + } as any); + expect(stack.actions?.map((a) => a.name)).toEqual(['probe_bound']); + expect(stack.objects?.find((o) => o.name === 'binder_note')?.actions).toBeUndefined(); + }); + + it('accepts a known platform object', () => { + const stack = defineStack({ manifest, objects: [OWN], actions: [bound('sys_organization')] } as any); + expect(stack.actions?.map((a) => a.objectName)).toEqual(['sys_organization']); + }); + + it('still refuses a name it cannot attribute — another package\'s object it does not extend', () => { + expect(refusalOf({ manifest, objects: [OWN], actions: [bound('crm_account')] })).toMatchObject({ + code: 'STACK_CROSS_REFERENCE_INVALID', + status: 422, + }); + }); + + it('still refuses a platform-prefixed name no platform package ships (the prefix is not the test)', () => { + const err = refusalOf({ manifest, objects: [OWN], actions: [bound('sys_organizaton')] }); + expect(err).toMatchObject({ code: 'STACK_CROSS_REFERENCE_INVALID', status: 422 }); + expect(err.message).toContain("Action 'probe_bound' references object 'sys_organizaton'"); + }); + + it('a same-stack bound action is merged into its object as before', () => { + const stack = defineStack({ manifest, objects: [OWN], actions: [bound('binder_note')] } as any); + expect(stack.objects?.find((o) => o.name === 'binder_note')?.actions?.map((a) => a.name)).toEqual(['probe_bound']); + }); +}); + +describe('the extension refusal names the route that works', () => { + it('`actions` on an extension is refused, and the guidance names the registry attach', () => { + const parsed = ObjectExtensionSchema.safeParse({ extend: 'crm_account', actions: [] }); + expect(parsed.success).toBe(false); + const text = JSON.stringify(parsed.error?.issues); + expect(text).toContain("objectName: ''"); + expect(text).toContain('schema registry attaches it to the target object'); + }); +}); From f1aefb178ec9f5ba5f3d869f521d5304e03bf977 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 03:28:26 +0000 Subject: [PATCH 3/3] wip: keep the object's own action order; attached ones follow --- .../src/bound-action-foreign-object.test.ts | 14 ++++++++++++++ packages/objectql/src/registry.ts | 11 +++++++++-- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/packages/objectql/src/bound-action-foreign-object.test.ts b/packages/objectql/src/bound-action-foreign-object.test.ts index 252af65e926..93583901f42 100644 --- a/packages/objectql/src/bound-action-foreign-object.test.ts +++ b/packages/objectql/src/bound-action-foreign-object.test.ts @@ -210,6 +210,20 @@ describe('a bound top-level action reaches another package\'s object as served', expect(attached).toMatchObject({ requiredPermissions: ['org_admin'], _packageId: 'com.test.binder' }); }); + it('the object\'s own actions keep their served order; attached ones follow, ordered by `order`', async () => { + const owner = structuredClone(OWNER) as any; + owner.objects[0].actions = [ + { name: 'second', label: 'Second', type: 'url', target: 'https://example.com/2', order: 20 }, + { name: 'first', label: 'First', type: 'url', target: 'https://example.com/1', order: 10 }, + ]; + const binder = { + id: 'com.test.binder', name: 'binder', + actions: [{ ...BOUND, name: 'late', order: 5 }, { ...BOUND, name: 'early', order: 1 }], + }; + const { engine } = await hostWith([owner, binder]); + expect(actionNames(engine.registry.getObject('ba_org'))).toEqual(['second', 'first', 'early', 'late']); + }); + it('control: an object nothing binds to is served as declared', async () => { const { protocol } = await hostWith([OWNER, BINDER, CONTROL]); const served: any = await protocol.getMetaItem({ type: 'object', name: 'ba_control' } as any); diff --git a/packages/objectql/src/registry.ts b/packages/objectql/src/registry.ts index c0897f845b0..4dff324af41 100644 --- a/packages/objectql/src/registry.ts +++ b/packages/objectql/src/registry.ts @@ -197,7 +197,7 @@ export function collectManifestObjectNames(manifest: any): string[] { * Stable sort by `order` (unset reads as `0`), by reference when no action * sets one — the rule `defineStack()` applies to each object's merged actions * (`sortActionsByOrder` in `@objectstack/spec`'s stack builder, which is - * internal there), so an attached action sorts exactly as a same-stack one. + * internal there), applied here to the attached actions among themselves. */ function sortActionsByOrder(actions: T[]): T[] { if (!actions.some((a) => a?.order !== undefined)) return actions; @@ -3506,6 +3506,11 @@ export class SchemaRegistry { * before rung 2). A target no package registers is refused at boot, not * here — see {@link findUnboundActionTargets}. * + * The object's own `actions` keep their order exactly — re-sorting them by + * `order` here would reorder an object no package bound anything to the + * moment one did. The attached actions follow, stable-sorted among + * themselves by `order`. + * * Returns `body` by reference when nothing attaches. */ private attachBoundActions(body: T): T { @@ -3524,7 +3529,9 @@ export class SchemaRegistry { fresh.push(action); } if (fresh.length === 0) return body; - return { ...body, actions: sortActionsByOrder([...base, ...fresh]) }; + // The object's own actions keep their served order, untouched; the + // attached ones follow, ordered among themselves by `order`. + return { ...body, actions: [...base, ...sortActionsByOrder(fresh)] }; } /**