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
27 changes: 27 additions & 0 deletions .changeset/25744-object-own-action-schema-parse.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
---
'@objectstack/objectql': patch
---

fix(objectql)!: an object's own actions are stored as their `ActionSchema` parse, so `requiresFeature` takes effect on a raw manifest's object actions

Clause-②: no (narrowing: an object's own action that fails `ActionSchema` is refused at the runtime registration door, as #25649 did for standalone actions)

Metadata-protocol: none

<!-- adr-0087: not-required (no-migration-prescription) No metadata moves: `ActionSchema` and `ObjectSchema` are byte-unchanged, and no spec key, authorable spelling, stored row or stored shape is added, removed, renamed or re-shaped, so there is nothing for `objectstack migrate meta` to rewrite and no old-to-new mapping to record. What changes is one runtime door converging on the existing contract: the schema registry now stores a code package's object with its own actions as their parse and refuses an object whose action does not parse. Every body it refuses was already off-contract, and refused by the authoring doors (`defineStack`, `os validate`, the metadata save door), before this change. The remedy is a correction of that body against the issues the refusal lists, not a conversion between two contract versions. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and nothing exported is removed or narrowed (not runtime-interface-only, type-surface-only or runtime-export-retired). -->

The schema registry now parses an object's own `actions` through `ActionSchema` when a code package registers the object (`SchemaRegistry.registerObject`), and stores the parsed actions. A standalone `action` item has been stored this way already; both now go through one parse. Before this, an object that reached the registry without a parse kept its actions as authored. Examples are a raw plugin manifest, a `strict: false` stack, and a programmatic `registerApp`. Such an object was served with its actions unparsed: a declared `requiresFeature` (or `requiresMembershipReach`) was never lowered into `visible`, so the gate did nothing.

What the parse does to such an action, as with any parsed action:

- it lowers `requiresFeature` / `requiresMembershipReach` into `visible`;
- it fills schema defaults;
- it wraps a CEL string shorthand in `{ dialect, source }`.

An object a strict `defineStack` or `ObjectSchema.create` already parsed is stored unchanged. A registration with no package, and a tenant-authored body (the metadata save door's write-through, the boot rehydration of stored rows), is not judged here.

**BREAKING** for a package that registers an object whose own action `ActionSchema` does not accept.

- **Before.** The registry stored such an object as written, and served the action with any declared gate inert.
- **Now.** The object's registration is refused with `INVALID_METADATA` (422), and nothing of it is stored. The error names the action, its object and its package, and `issues[]` gives the path (`actions.N.KEY`) and message of each failure.
- **Fix.** Correct the action as `issues[]` describes. An object that passes `os validate`, or a strict `defineStack`, is unaffected.
160 changes: 160 additions & 0 deletions packages/objectql/src/object-own-action-schema-parse.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#25744] An object's OWN `actions`, registered by a code package through
* {@link SchemaRegistry.registerObject}, are stored as their `ActionSchema`
* parse — the parse a standalone `action` item is stored as since #25649 — so a
* declared `requiresFeature` / `requiresMembershipReach` is lowered into
* `visible`, and an action that does not parse is refused (`INVALID_METADATA`,
* 422) before anything of the object is stored.
*
* Before, a raw manifest or a `strict: false` stack registered with
* `ObjectQL.registerApp` stored its objects' actions as written: served by
* `GET /api/v1/meta/objects/:name` (`protocol.getMetaItem({ type: 'object' })`)
* with `requiresFeature` riding along raw and no `visible` — a feature gate no
* reader enforces. A strict `defineStack`, `os validate` and the metadata save
* door already parse the same body through `ActionSchema`.
*/

import { describe, it, expect } from 'vitest';
import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol';
import { defineStack } from '@objectstack/spec';
import { ObjectSchema } from '@objectstack/spec/data';
import { SysMetadataAuditObject, SysMetadataCommitObject, SysMetadataHistoryObject } from '@objectstack/metadata-core';
import { ObjectQL } from './engine.js';

/** One gated declaration, authored on an object's own action. */
const GATED = {
name: 'gated', label: 'Gated', type: 'url' as const, target: 'https://example.com/gated',
locations: ['record_header' as const], visible: "record.name != ''",
requiresFeature: 'multiOrgEnabled' as const,
};
const LOWERED = { dialect: 'cel', source: "(record.name != '') && features.multiOrgEnabled != false" };

const objectWith = (actions: unknown[]) => ({
name: 'oa_org', label: 'Org',
fields: { name: { name: 'name', type: 'text', label: 'Name' } },
actions,
});
const rawManifest = (actions: unknown[]) => ({ id: 'com.test.raw', name: 'raw', objects: [objectWith(actions)] });

/** A driver that holds nothing: the reads below never reach a stored row. */
const emptyDriver: any = {
name: 'memory', version: '0.0.0', supports: {},
async connect() {}, async disconnect() {}, async checkHealth() { return true; },
async execute() { return null; },
async find() { return []; }, async findOne() { return null; }, async count() { return 0; },
};

/** The stored-metadata family the by-name read consults for an overlay row (none here). */
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 },
package_id: { name: 'package_id', label: 'Package', type: 'text' as const },
metadata: { name: 'metadata', label: 'Body', type: 'textarea' as const },
state: { name: 'state', label: 'State', type: 'text' as const },
},
};

async function host() {
const engine = new ObjectQL();
engine.registerDriver(emptyDriver, true);
await engine.init();
engine.registry.registerObject(sysMetadataObject);
for (const o of [SysMetadataHistoryObject, SysMetadataAuditObject, SysMetadataCommitObject]) engine.registry.registerObject(o as any);
return { engine, protocol: new ObjectStackProtocolImplementation(engine) };
}

const servedAction = async (protocol: ObjectStackProtocolImplementation, name: string) => {
const served: any = await protocol.getMetaItem({ type: 'object', name: 'oa_org' } as any);
return (served.item.actions as any[]).find((a) => a?.name === name);
};

const refusalOf = (fn: () => unknown): any => {
try { fn(); } catch (e) { return e; }
return undefined;
};

describe("an object's own action is stored as its ActionSchema parse", () => {
it('a raw manifest: GET /meta/object/<name> serves `requiresFeature` lowered into `visible`', async () => {
const { engine, protocol } = await host();
engine.registerApp(rawManifest([GATED]));
const served = await servedAction(protocol, 'gated');
expect(served.visible).toEqual(LOWERED);
expect(served).not.toHaveProperty('requiresFeature');
// The same lowering `ObjectSchema.create` gives a platform object's action of the same declaration.
const authored: any = ObjectSchema.create(objectWith([GATED]) as any);
expect(served.visible).toEqual(authored.actions[0].visible);
// The registry's own resolved object holds the same parsed body.
expect(engine.registry.getObject('oa_org')?.actions?.[0]).toEqual(served);
});

it('an action with no `visible` of its own is served the gate alone', async () => {
const { visible: _visible, ...ungated } = GATED;
const { engine, protocol } = await host();
engine.registerApp(rawManifest([ungated]));
const served = await servedAction(protocol, 'gated');
expect(served.visible).toEqual({ dialect: 'cel', source: 'features.multiOrgEnabled != false' });
expect(served).not.toHaveProperty('requiresFeature');
});

it('`requiresMembershipReach` is lowered the same way', async () => {
const { requiresFeature: _feature, ...rest } = GATED;
const { engine, protocol } = await host();
engine.registerApp(rawManifest([{ ...rest, requiresMembershipReach: 'invite_member' }]));
const served = await servedAction(protocol, 'gated');
expect(served).not.toHaveProperty('requiresMembershipReach');
expect(served.visible).toEqual(ObjectSchema.create(objectWith([{ ...rest, requiresMembershipReach: 'invite_member' }]) as any).actions![0].visible);
});

it('a `strict: false` stack registered with registerApp is parsed at the registry', async () => {
const stack: any = defineStack({
manifest: { id: 'com.test.loose', name: 'loose', version: '1.0.0', type: 'app' },
objects: [objectWith([GATED])],
} as any, { strict: false });
// The loose stack hands the body on as written.
expect(stack.objects[0].actions[0]).toHaveProperty('requiresFeature');
const { engine, protocol } = await host();
engine.registerApp({ ...stack, ...stack.manifest });
const served = await servedAction(protocol, 'gated');
expect(served.visible).toEqual(LOWERED);
expect(served).not.toHaveProperty('requiresFeature');
});

it('a direct registerObject under a package is parsed; the caller\'s body is not mutated', async () => {
const { engine } = await host();
const def = objectWith([structuredClone(GATED)]);
engine.registry.registerObject(def as any, 'com.test.direct');
expect(engine.registry.getObject('oa_org')?.actions?.[0]).toMatchObject({ visible: LOWERED });
expect(def.actions[0]).toHaveProperty('requiresFeature', 'multiOrgEnabled');
});

it('control: an object a strict parse already produced is stored deep-equal to what it arrived as', async () => {
const authored: any = ObjectSchema.create(objectWith([GATED]) as any);
const { engine, protocol } = await host();
engine.registerApp({ id: 'com.test.strict', name: 'strict', objects: [structuredClone(authored)] });
expect(await servedAction(protocol, 'gated')).toEqual(authored.actions[0]);
});

it('an action that does not parse is refused loudly, naming the action, its object and package — nothing stored', async () => {
const { engine } = await host();
const err = refusalOf(() => engine.registerApp(rawManifest([{ ...GATED, visible: false }])));
expect(err).toMatchObject({ code: 'INVALID_METADATA', status: 422 });
expect(err.message).toContain("Action 'gated' of object 'oa_org' in package 'com.test.raw' does not satisfy the action contract");
expect(err.issues).toEqual(expect.arrayContaining([expect.objectContaining({ path: 'actions.0.requiresFeature' })]));
expect(engine.registry.getObject('oa_org')).toBeUndefined();
});

it('a refused re-registration leaves the object already registered under that package in place', async () => {
const { engine } = await host();
engine.registerApp(rawManifest([GATED]));
const err = refusalOf(() => engine.registry.registerObject(objectWith([{ ...GATED, bogus: true }]) as any, 'com.test.raw'));
expect(err).toMatchObject({ code: 'INVALID_METADATA', status: 422 });
expect(err.issues).toEqual(expect.arrayContaining([expect.objectContaining({ code: 'unrecognized_keys' })]));
expect(engine.registry.getObject('oa_org')?.actions?.[0]).toMatchObject({ name: 'gated', visible: LOWERED });
});
});
72 changes: 69 additions & 3 deletions packages/objectql/src/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,17 +194,35 @@ interface PackageActionSchemaError extends Error {
* Refused rather than stored unparsed: an action that fails the parse would be
* served with whatever gates it declares never taking effect, and `defineStack`
* and `os validate` refuse the same declaration at authoring time.
*
* [#25744] The one parse for both places a code package declares an action:
* a standalone `action` item ({@link SchemaRegistry.registerItem}) and an
* object's own `actions` ({@link parsePackageObjectActions}, called by
* {@link SchemaRegistry.registerObject}). `owner` names the object an own
* action is declared on, so the refusal says which object and `issues[]`
* locates each failure inside its body.
*/
function parsePackageAction(item: unknown, name: string, packageId: string): unknown {
function parsePackageAction(
item: unknown,
name: string,
packageId: string,
owner?: { object: string; index: number },
): unknown {
const parsed = ActionSchema.safeParse(item);
if (parsed.success) return parsed.data;
// [#25744] An object's own action is located inside the object body it was
// declared in (`actions.<index>.<key>`), the path `ObjectSchema` reports.
const prefix = owner ? ['actions', String(owner.index)] : [];
const issues = parsed.error.issues.map((issue) => ({
path: issue.path.map(String).join('.'),
path: [...prefix, ...issue.path.map(String)].join('.'),
message: issue.message,
code: issue.code,
}));
const subject = owner
? `Action '${name}' of object '${owner.object}' in package '${packageId}'`
: `Action '${name}' of package '${packageId}'`;
const err = new Error(
`Action '${name}' of package '${packageId}' does not satisfy the action contract (ActionSchema): `
`${subject} does not satisfy the action contract (ActionSchema): `
+ issues.map((issue) => (issue.path ? `${issue.path}: ${issue.message}` : issue.message)).join('; ')
+ '. Every action a package ships is stored as its schema parse — the parse that lowers '
+ '`requiresFeature` and `requiresMembershipReach` into `visible` — so an action that does not parse '
Expand All @@ -218,6 +236,35 @@ function parsePackageAction(item: unknown, name: string, packageId: string): unk
throw err;
}

/**
* [#25744] A code package's object, its own `actions` stored as their
* `ActionSchema` parse ({@link parsePackageAction}) — or the loud refusal of
* the first one that does not parse.
*
* The object-path twin of {@link SchemaRegistry.registerItem}'s action parse.
* `ObjectSchema`'s `actions` is `ActionSchema[]`, so a platform object
* (`ObjectSchema.create`) and a strict `defineStack` already hand this method
* parsed actions, and the parse being idempotent over its own output stores
* them deep-equal to what arrived. A raw manifest, a `strict: false` stack or a
* programmatic `registerApp` reached the store with its objects' actions as
* written, so a declared `requiresFeature` / `requiresMembershipReach` was
* served raw, never lowered into `visible`.
*
* Returns `schema` by reference when it declares no `actions` array, and a
* shallow copy carrying the parsed array otherwise: the caller's body is
* never mutated.
*/
function parsePackageObjectActions(schema: ServiceObject, packageId: string): ServiceObject {
const actions = (schema as { actions?: unknown }).actions;
if (!Array.isArray(actions) || actions.length === 0) return schema;
const parsed = actions.map((action, index) => {
const declared = (action as { name?: unknown } | null | undefined)?.name;
const name = typeof declared === 'string' && declared !== '' ? declared : `#${index}`;
return parsePackageAction(action, name, packageId, { object: schema.name, index });
});
return { ...schema, actions: parsed } as ServiceObject;
}

/**
* [#24472] The key {@link SchemaRegistry}'s bound-action collision audit is
* carried under. A symbol, and ⛔ deliberately NOT re-exported from the package
Expand Down Expand Up @@ -3442,6 +3489,25 @@ export class SchemaRegistry {
throw new ExtensionMembershipKeysRefusedError(schema.name, packageId);
}

// [#25744] A code package's object keeps its own actions as their
// `ActionSchema` parse — `requiresFeature` / `requiresMembershipReach`
// lowered into `visible` — or is refused here, beside the doors above and
// for their reason: before any materialization and before the contributor
// list is opened, so a refused object leaves nothing behind and a refused
// re-registration leaves the registered one in place
// ({@link parsePackageObjectActions}). The test is `registerItem`'s action
// parse's, asked of the body as `applyProtection` below will stamp it (a
// package id it does not already carry, `'package'` provenance): ⛔ a
// registration with no `packageId`, the `sys_metadata` sentinel, or a
// tenant-authored body (`_provenance: 'org'` — the save door's write-through
// and the boot rehydration) is never judged here.
if (packageId && isCodeArtifactBody({
_packageId: (schema as { _packageId?: unknown })._packageId ?? packageId,
_provenance: (schema as { _provenance?: unknown })._provenance ?? 'package',
})) {
schema = parsePackageObjectActions(schema, packageId);
}

// [#23065] A `tree` field that names no `reference` references the object
// that declares it, and this is where that is written down for every door
// the parse does not cover: a metadata save stores the authored body
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -594,7 +594,8 @@ function actionPackageOf(prefix: string) {
const base: any = pkg.artifact();
base.objects[0].actions = [
update('mark_done', true),
{ name: 'ping', label: 'Ping', type: 'script', target: 'qa_ping', ai: { exposed: true, description: 'Ping' } },
// [#25744] An object's own action is stored as its `ActionSchema` parse too.
{ name: 'ping', label: 'Ping', type: 'script', target: 'qa_ping', ai: { exposed: true, description: 'Ping: answers pong, for the MCP discovery test' } },
update('hidden_done', false),
];
base.objects[1].actions = [update('gated_done', true)];
Expand Down
Loading
Loading