From 77025c749337e808818023ac74aa91c241101c4c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 06:11:05 +0000 Subject: [PATCH 1/3] fix(objectql): store an object's own actions as their ActionSchema parse at registerObject A code package's object registered through SchemaRegistry.registerObject (a raw manifest, a strict: false stack, a programmatic registerApp) now keeps its own actions as their ActionSchema parse, so a declared requiresFeature / requiresMembershipReach is lowered into visible. An action that does not parse is refused with INVALID_METADATA (422) before anything is stored. One parse helper (parsePackageAction) serves both the standalone action item and the object's own actions. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_012FM5zuP45LkwYGZfazCj5z --- .../object-own-action-schema-parse.test.ts | 160 ++++++++++++++++++ packages/objectql/src/registry.ts | 72 +++++++- 2 files changed, 229 insertions(+), 3 deletions(-) create mode 100644 packages/objectql/src/object-own-action-schema-parse.test.ts diff --git a/packages/objectql/src/object-own-action-schema-parse.test.ts b/packages/objectql/src/object-own-action-schema-parse.test.ts new file mode 100644 index 00000000000..c6017f37648 --- /dev/null +++ b/packages/objectql/src/object-own-action-schema-parse.test.ts @@ -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/ 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 }); + }); +}); diff --git a/packages/objectql/src/registry.ts b/packages/objectql/src/registry.ts index 0aa4421767c..ca946b02a92 100644 --- a/packages/objectql/src/registry.ts +++ b/packages/objectql/src/registry.ts @@ -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..`), 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 ' @@ -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 @@ -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 From 3867ab77db6efe2dc7eb8a149e9328056ff3f6c8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 06:26:08 +0000 Subject: [PATCH 2/3] chore(changeset): object own-action ActionSchema parse at registerObject Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_012FM5zuP45LkwYGZfazCj5z --- .../25744-object-own-action-schema-parse.md | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 .changeset/25744-object-own-action-schema-parse.md diff --git a/.changeset/25744-object-own-action-schema-parse.md b/.changeset/25744-object-own-action-schema-parse.md new file mode 100644 index 00000000000..c9db87197b4 --- /dev/null +++ b/.changeset/25744-object-own-action-schema-parse.md @@ -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 + + + +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. From cf6bf094b94601e90a9912f0c0b426e1352f6600 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 07:17:34 +0000 Subject: [PATCH 3/3] test(runtime): object-action fixtures name what ActionSchema requires The registry now stores an object's own actions as their ActionSchema parse, so the reader-seam pin's host-handler script actions name their target and the MCP discovery fixture's AI-exposed ping carries a 40-character description. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_012FM5zuP45LkwYGZfazCj5z --- .../src/domains/mcp-package-object-discovery.test.ts | 3 ++- .../src/stored-metadata-reader-contexts.pin.test.ts | 11 +++++++---- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/packages/runtime/src/domains/mcp-package-object-discovery.test.ts b/packages/runtime/src/domains/mcp-package-object-discovery.test.ts index a1eae33f288..831ddeba1ee 100644 --- a/packages/runtime/src/domains/mcp-package-object-discovery.test.ts +++ b/packages/runtime/src/domains/mcp-package-object-discovery.test.ts @@ -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)]; diff --git a/packages/runtime/src/stored-metadata-reader-contexts.pin.test.ts b/packages/runtime/src/stored-metadata-reader-contexts.pin.test.ts index a27367f38f5..ae43fc910d7 100644 --- a/packages/runtime/src/stored-metadata-reader-contexts.pin.test.ts +++ b/packages/runtime/src/stored-metadata-reader-contexts.pin.test.ts @@ -162,9 +162,12 @@ const PIN_APP: any = { ), }, // ② / ③ declared here; their handlers are code registered by PIN_HANDLER_PLUGIN. - { name: 'handler_engine_reads_family', label: 'Handler engine read', type: 'script' }, - { name: 'handler_engine_reads_history', label: 'Handler engine history read', type: 'script' }, - { name: 'handler_api_reads_family', label: 'Handler api read', type: 'script' }, + // [#25744] An object's own action is stored as its `ActionSchema` parse + // too, so each names its handler (`target`), as a body-less `script` + // action must; dispatch is by handler key, so the pins' subject is unchanged. + { name: 'handler_engine_reads_family', label: 'Handler engine read', type: 'script', target: 'handler_engine_reads_family' }, + { name: 'handler_engine_reads_history', label: 'Handler engine history read', type: 'script', target: 'handler_engine_reads_history' }, + { name: 'handler_api_reads_family', label: 'Handler api read', type: 'script', target: 'handler_api_reads_family' }, // EVALUATE shapes — each body attempts to evaluate the stored body or // hash. [#21594] A body's read is refused whatever it carries, so each // answers the body boundary's refusal, the same as a plain read. The @@ -222,7 +225,7 @@ const PIN_APP: any = { }, // ② the engine handle's evaluate shape — a handler whose ctx.engine.find // filters the body column is refused the same way. - { name: 'handler_engine_filters_body', label: 'Handler engine filters body', type: 'script' }, + { name: 'handler_engine_filters_body', label: 'Handler engine filters body', type: 'script', target: 'handler_engine_filters_body' }, // [#21594] subject-record control: an ordinary row is handed to a body as before. { name: 'note_reads_record', label: 'Body reads its subject record', type: 'script', body: actionBody(RETURN_RECORD) }, ],