Skip to content

Commit e44a1ff

Browse files
fix(objectql)!: store a code package's action as its ActionSchema parse, so a bound action's requiresFeature is lowered (#25699)
Fixes #25649 Clause-②: no (narrowing) — `SchemaRegistry.registerItem`, and with it `ObjectQL.registerApp` and every door that registers a code package's items, now refuses with `422 INVALID_METADATA` an `action` body that does not satisfy `ActionSchema`. Before, it registered that body as written. Nothing is widened. Metadata-protocol: none — `ActionSchema` is unchanged and no key is added, retired or re-shaped. Every body the registry now refuses already fails the same schema at `defineStack`, `os validate` and the metadata save door, so what a metadata author can write under the protocol does not move. The runtime door converges on it. **BREAKING** for a package that registers an action body `ActionSchema` does not accept. Such a body used to be stored as written, with its declared gates inert. Now its registration is refused with `INVALID_METADATA` (422), naming the action and its package, and `issues[]` locates each failure. An action that passes `os validate` or a strict `defineStack` is unaffected. ADR-0087 disposition (in the changeset): `not-required (no-migration-prescription)`. `ActionSchema` is byte-unchanged, so there is nothing for `objectstack migrate meta` to rewrite. ## What changed `SchemaRegistry.registerItem` (`packages/objectql/src/registry.ts`) now stores a code package's `action` item as its `ActionSchema` parse. If the item does not parse, the registry refuses it loudly. Before, the item was stored as the package wrote it. - **Where.** `registerItem` is the one site every code-package action passes: `ObjectQL.registerApp`, nested plugins, the metadata facade and the ObjectQL plugin's metadata-service sync all register through it. The new step runs right after `applyProtection` and before anything is stored. It applies when the type is `action`, a `packageId` is passed, and `isCodeArtifactBody` holds. A registration with no `packageId` is the bare slot (`sys_metadata` hydration, the write-through) and is not judged here, the same rule the security-catalog and default-page checks in that method follow. - **Why the stored item has to be the parse.** `ActionSchema`'s transform lowers `requiresMembershipReach` and `requiresFeature` into `visible` and deletes both keys. No runtime code reads either key raw. So an unparsed stored action carried both gates declared and inert, and `attachBoundActions` served it that way. Platform objects' actions are parsed where they are authored (`ObjectSchema.create`), which is why `rename_organization` was lowered and cloud's bound `change_slug` was not. - **Identity kept.** The attached entry is still the very item the router's rung 2 resolves (`toBe` pinned). `attachBoundActions` is unchanged, so rung 1 before rung 2 and dedupe by name both hold. - **Refusal.** An action that fails the parse throws the `INVALID_METADATA` / 422 envelope (ADR-0112), the code this package already uses for metadata that fails the spec. The message names the action and its package. `issues[]` gives each failure with `path`, `message` and `code`. No new error code. ## What the parse does to a code-package action (measured) Measured on `@objectstack/spec` at this branch's base, with a stamped body (`applyProtection`, package id and version): - `requiresFeature` is lowered: `visible: "record.x == 1"` with `requiresFeature: multiOrgEnabled` becomes `{ dialect: 'cel', source: '(record.x == 1) && features.multiOrgEnabled != false' }`, and the key is deleted. `requiresMembershipReach` is lowered the same way. - Defaults are filled, e.g. `refreshAfter: false`, and `type` defaults to `'script'`. - A CEL string shorthand is wrapped as `{ dialect, source }`. - An undeclared key is refused (`unrecognized_keys`; the schema is closed). - The ADR-0010 envelope (`_packageId`, `_packageVersion`, `_provenance`, `_lock`) is declared on the schema and passes through. - The parse is idempotent: `parse(parse(x))` equals `parse(x)`. **In-repo shipped actions: none refused.** The registry door's accept set narrows (above), but every top-level action the in-repo stacks ship goes through a strict `defineStack` or `defineAction`, and all of them parse. I ran each stamped action through `ActionSchema.safeParse` and compared the canonical (key-sorted) JSON before and after: | Stack | Actions | Bound | Refused | Changed | |---|---|---|---|---| | `examples/app-crm` | 1 | 1 | 0 | 0 | | `examples/app-todo` | 8 | 8 | 0 | 0 | | `examples/app-showcase` (`allActions`) | 72 | 71 | 0 | 0 | No top-level `actions:` collection exists in `packages/plugins/**`, `packages/services/**` or `packages/runtime/src/**` source. The refusal reaches only the test fixtures triaged below. ## Tests New, in `packages/objectql/src/bound-action-foreign-object.test.ts`, through the real `ObjectQL.registerApp` and `protocol.getMetaItem({ type: 'object' })`, the read behind `GET /api/v1/meta/objects/:name`: - A package-bound action with `requiresFeature: 'multiOrgEnabled'` and a CEL `visible` is served with the conjunct lowered, `(record.name != '') && features.multiOrgEnabled != false`, and with no `requiresFeature` key. The value equals what the platform object's own action with the same declaration (`ObjectSchema.create`) is served. The attachment is `toBe` the registry item. - A bound action with no `visible` of its own is served the gate alone. - Control: the platform object's own action is served exactly as `ObjectSchema.create` produced it. - Refusal: a bound action with `requiresFeature` beside `visible: false` is refused with `INVALID_METADATA` / 422. The message names `change_slug` and `com.test.binder`, `issues[]` contains `path: 'requiresFeature'`, and the item is not stored or attached. **Ablation** (`scripts/ablation-replace.mjs`, wrap mode). The test imports `./registry.js` from source, so no dist leg applies. The anchor `item = parsePackageAction(item, baseName, packageId) as T;` hit once and was replaced by a comment; the blob went `0aa44217` to `07883fa6`. Result: 3 tests failed and 15 passed. The three red were the lowering, gate-alone and refusal tests; the control stayed green, as intended. Restore: blob `0aa44217` equals HEAD and `git diff HEAD` is empty. ### Fixture triage (the refusal's consumer radius) Each fixture below registered an off-spec action body. Each one got the missing declaration ("supply the declaration"). Nothing pinned the removed behaviour: - `objectql/src/package-disable-enforcement.test.ts`: the shipped `action` specimen gains `type: 'url'` and `target`. - `objectql/src/engine-nested-plugin-collections.test.ts`: the `actions` probe gains `type: 'url'` and `target`. - `objectql/src/registry-package-version-stamp.test.ts`: the `script` action gains a `target`. - `objectql/src/registry.test.ts`: the "generic metadata" cases now use type `report`; `action` is no longer a generic item. - `objectql/src/bound-action-collision-report.test.ts`: the owner's object is authored with `ObjectSchema.create`, as an object is, so its action is the parse of what was written, like the carrier's registered copy. - `runtime/src/domains/mcp-package-object-discovery.test.ts`: the AI `description` is 40 characters or more, the length `ActionSchema` requires. - `runtime/src/stored-metadata-reader-contexts.pin.test.ts`: the host-handler `script` action names its `target`. The runtime never reads a `script` action's `target` server-side, since dispatch is by handler key, so the pin's subject is unchanged. ## Local verification Final head `f9330127c2`. The rework commit changes only the changeset. - **Dogfood battery, full**, under the verify lock with CI's command (`pnpm --filter @objectstack/dogfood test`, no `OS_TEST_SHARD`, no paths), after building the `@objectstack/dogfood^...` closure: `Test Files 295 passed | 3 skipped (298)`, `Tests 2520 passed | 18 skipped (2538)`, exit 0. Lock: `VERDICT command-exit 0`. - `@objectstack/objectql` local project, full: `Test Files 466 passed (466)`, `Tests 8704 passed (8704)`, exit 0. - `@objectstack/objectql` typecheck: exit 0, including `check:test-typecheck: OK`. - `node scripts/check-adr-0087-registration.mjs --base origin/main`: exit 0. It reads one declared-breaking changeset, `[BREAKING+bang+clause-②-narrowing]`, with disposition `not-required (no-migration-prescription)`. - `node scripts/check-changeset-no-major.mjs --base origin/main`: exit 0. With `--event` carrying this body, it also exits 0 and reads `clause-② no (narrowing)`, metadata-protocol `none`, no changeset graded `minor` or above. - `node scripts/check-empty-changeset.mjs --base origin/main`: exit 0. - `eslint --no-inline-config` over the 9 touched `.ts` files: exit 0. - Earlier rounds, on code unchanged since: `@objectstack/runtime` local project full (441 files; the one red file was triaged, and both triaged files rerun green, 171 tests), runtime typecheck exit 0, `@objectstack/rest` full (325 files), `@objectstack/metadata-protocol` full (263 passed, 3 skipped). At `89ecd31788`, the 74 families `dispatch-gates.mjs --commands` derived all exit 0 (`--ran`: 74 derived, 74 run, 0 NOT-MEASURED). ## Acceptance notes - **Boundary: an object's own actions on a raw manifest stay unparsed.** This PR does not change that seam. A raw manifest or a `strict: false` stack registered with `registerApp` stores its objects' own `actions` unparsed, because the object path's `validate` is a diagnostic only. Measured: a probe registering `{ objects: [{ …, actions: [{ name: 'gated', …, requiresFeature: 'multiOrgEnabled' }] }] }` reads back from `protocol.getMetaItem({ type: 'object' })` with `requiresFeature` raw and no `visible`. This is the same declared-not-enforced family; the seat carries it, outside this PR. - **Boundary: tenant overlay rows.** A stored overlay of a packaged action is registered in the bare slot and is not parsed here. It is parsed at the metadata save door. Whether that door stores the parsed body or the authored one was not measured. --- _Generated by [Claude Code](https://claude.ai/code/session_01JVTd3PprStGa26aez5omLB)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3b8ea15 commit e44a1ff

10 files changed

Lines changed: 218 additions & 16 deletions
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
---
2+
'@objectstack/objectql': patch
3+
---
4+
5+
fix(objectql)!: a code package's action is stored as its `ActionSchema` parse, so `requiresFeature` takes effect on bound actions
6+
7+
Clause-②: no (narrowing) — `SchemaRegistry.registerItem`, and with it `ObjectQL.registerApp` and every door that registers a code package's items, now refuses with `422 INVALID_METADATA` an `action` body that does not satisfy `ActionSchema`. Before, it registered that body as written. Nothing is widened.
8+
9+
Metadata-protocol: none (`ActionSchema` is unchanged and no key is added, retired or re-shaped; every body the registry now refuses already fails the same schema at `defineStack`, `os validate` and the metadata save door)
10+
11+
<!-- adr-0087: not-required (no-migration-prescription) No metadata moves: `ActionSchema` is 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 action as its parse and refuses one that does not parse. Every body it refuses was already off-contract, and refused by the authoring doors, 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). -->
12+
13+
The schema registry now parses every `action` item a code package registers through `ActionSchema` before storing it. The stored item is the parsed body. Before this, an action that reached the registry without a parse was stored as authored. Examples are a programmatic `registerApp`, a nested plugin's manifest, a `strict: false` stack, and a raw plugin manifest. Such an action was then attached to its target object unparsed. Its `requiresFeature` (and `requiresMembershipReach`) was never lowered into `visible`, so the declared gate did nothing. A platform object's own actions were already lowered.
14+
15+
What the parse does to such an action, as with any parsed action:
16+
17+
- it lowers `requiresFeature` / `requiresMembershipReach` into `visible`;
18+
- it fills schema defaults;
19+
- it wraps a CEL string shorthand in `{ dialect, source }`.
20+
21+
An action a strict `defineStack` already parsed is stored unchanged.
22+
23+
**BREAKING** for a package that registers an action body `ActionSchema` does not accept.
24+
25+
- **Before.** The registry stored such a body as written, and served it with any declared gate inert.
26+
- **Now.** Registration is refused with `INVALID_METADATA` (422). The error names the action and its package, and `issues[]` gives the path and message of each failure. The registration that carried it fails.
27+
- **Fix.** Correct the action as `issues[]` describes. An action that passes `os validate`, or a strict `defineStack`, is unaffected.

‎packages/objectql/src/bound-action-collision-report.test.ts‎

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,18 +18,23 @@
1818
import { describe, it, expect, afterEach, vi } from 'vitest';
1919
import { ObjectKernel, type PluginContext } from '@objectstack/core';
2020
import { defineStack } from '@objectstack/spec';
21+
import { ObjectSchema } from '@objectstack/spec/data';
2122
import { ObjectQL } from './engine.js';
2223
import { ObjectQLPlugin } from './plugin.js';
2324
import { FIND_BOUND_ACTION_COLLISIONS, SchemaRegistry } from './registry.js';
2425

25-
/** The package that OWNS the target object, with an action of its own. */
26+
/**
27+
* The package that OWNS the target object, with an action of its own — authored
28+
* as an object is (`ObjectSchema.create`), so its action is the parse of what
29+
* was written, as a bound action's registered copy is (#25649).
30+
*/
2631
const OWNER = {
2732
id: 'com.test.owner', name: 'owner',
28-
objects: [{
33+
objects: [ObjectSchema.create({
2934
name: 'bc_org', label: 'Org',
3035
fields: { name: { name: 'name', type: 'text', label: 'Name' } },
3136
actions: [{ name: 'rename', label: 'Rename', type: 'url', target: 'https://example.com/own', locations: ['record_header'] }],
32-
}],
37+
} as any)],
3338
};
3439

3540
/** Another package binding an action of the same NAME to the owner's object — a different declaration. */
@@ -41,7 +46,7 @@ const TWIN = {
4146
/** Another package carrying the owner's declaration itself, bound by `objectName` — one declaration, two packages. */
4247
const CARRIER = {
4348
id: 'com.test.carrier', name: 'carrier',
44-
actions: [{ ...OWNER.objects[0].actions[0], objectName: 'bc_org' }],
49+
actions: [{ ...OWNER.objects[0].actions![0], objectName: 'bc_org' }],
4550
};
4651

4752
/** A package that brings an unrelated object — the late-artifact control. */

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

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { describe, it, expect, afterEach } from 'vitest';
2020
import { ObjectKernel, type PluginContext } from '@objectstack/core';
2121
import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol';
2222
import { defineStack } from '@objectstack/spec';
23+
import { ObjectSchema } from '@objectstack/spec/data';
2324
import { ObjectQL } from './engine.js';
2425
import { ObjectQLPlugin } from './plugin.js';
2526
import { describeUnboundActionTargets } from './registry.js';
@@ -351,3 +352,80 @@ describe('the bound-action boot audit', () => {
351352
expect(err?.message).toContain("action 'b' (a registration with no package) is bound to object 'y'");
352353
});
353354
});
355+
356+
// ── The parse a bound action is stored as ─────────────────────────────────
357+
358+
/**
359+
* [#25649] A package's bound action is stored as its `ActionSchema` parse, so
360+
* a declared `requiresFeature` is lowered into `visible` exactly as on a
361+
* platform object's own action (`ObjectSchema.create` parses those where they
362+
* are authored). Before, the registry stored the item as the package wrote it:
363+
* served with `requiresFeature` riding along raw and its `visible` the bare
364+
* predicate — a feature gate no reader enforces.
365+
*/
366+
describe('a bound action is served as its ActionSchema parse', () => {
367+
/** One declaration, carried both by the owner's object and by a binder package. */
368+
const GATED = {
369+
label: 'Change slug', type: 'url' as const, target: 'https://example.com/slug',
370+
locations: ['record_header' as const], visible: "record.name != ''",
371+
requiresFeature: 'multiOrgEnabled' as const,
372+
};
373+
const LOWERED = { dialect: 'cel', source: "(record.name != '') && features.multiOrgEnabled != false" };
374+
375+
/** The owner's object as a platform object is authored: parsed by `ObjectSchema.create`. */
376+
const platformOwner = () => ({
377+
id: 'com.test.owner', name: 'owner',
378+
objects: [ObjectSchema.create({
379+
name: 'ba_org', label: 'Org',
380+
fields: { name: { name: 'name', type: 'text', label: 'Name' } },
381+
actions: [{ name: 'own_gated', ...GATED }],
382+
} as any)],
383+
});
384+
const gatedBinder = (action: Record<string, unknown>) => ({ id: 'com.test.binder', name: 'binder', actions: [action] });
385+
const servedAction = async (protocol: ObjectStackProtocolImplementation, name: string) => {
386+
const served: any = await protocol.getMetaItem({ type: 'object', name: 'ba_org' } as any);
387+
return (served.item.actions as any[]).find((a) => a?.name === name);
388+
};
389+
390+
it('GET /meta/object/<target> serves the bound action with `requiresFeature` lowered into `visible`', async () => {
391+
const { engine, protocol } = await hostWith([platformOwner(), gatedBinder({ name: 'change_slug', objectName: 'ba_org', ...GATED })]);
392+
const bound = await servedAction(protocol, 'change_slug');
393+
expect(bound.visible).toEqual(LOWERED);
394+
expect(bound).not.toHaveProperty('requiresFeature');
395+
// The same lowering the platform object's own action, of the same declaration, is served with.
396+
expect(bound.visible).toEqual((await servedAction(protocol, 'own_gated')).visible);
397+
// One parsed body: the attachment is the very item the router's rung 2 resolves.
398+
expect(engine.registry.getObject('ba_org')?.actions?.find((a: any) => a.name === 'change_slug'))
399+
.toBe(engine.registry.getItem('action', 'change_slug'));
400+
});
401+
402+
it('a bound action with no `visible` of its own is served the gate alone', async () => {
403+
const { requiresFeature, label, type, target, locations } = GATED;
404+
const { protocol } = await hostWith([platformOwner(), gatedBinder({ name: 'change_slug', objectName: 'ba_org', label, type, target, locations, requiresFeature })]);
405+
const bound = await servedAction(protocol, 'change_slug');
406+
expect(bound.visible).toEqual({ dialect: 'cel', source: 'features.multiOrgEnabled != false' });
407+
expect(bound).not.toHaveProperty('requiresFeature');
408+
});
409+
410+
it('control: the platform object\'s own action is served as `ObjectSchema.create` produced it', async () => {
411+
const owner = platformOwner();
412+
const authored = structuredClone(owner.objects[0].actions!.find((a: any) => a.name === 'own_gated'))!;
413+
const { protocol } = await hostWith([owner, gatedBinder({ name: 'change_slug', objectName: 'ba_org', ...GATED })]);
414+
expect(await servedAction(protocol, 'own_gated')).toEqual(authored);
415+
expect(authored.visible).toEqual(LOWERED);
416+
});
417+
418+
it('a bound action that does not parse is refused loudly, naming the action and its package — never stored', async () => {
419+
const host = await hostWith([platformOwner()]);
420+
const err: any = (() => {
421+
try { host.engine.registerApp(gatedBinder({ name: 'change_slug', objectName: 'ba_org', ...GATED, visible: false })); }
422+
catch (e) { return e; }
423+
return undefined;
424+
})();
425+
expect(err).toMatchObject({ code: 'INVALID_METADATA', status: 422 });
426+
expect(err.message).toContain("Action 'change_slug' of package 'com.test.binder' does not satisfy the action contract");
427+
expect(err.issues).toEqual(expect.arrayContaining([expect.objectContaining({ path: 'requiresFeature' })]));
428+
expect(host.engine.registry.getItem('action', 'change_slug')).toBeUndefined();
429+
expect(actionNames(host.engine.registry.getObject('ba_org'))).toEqual(['own_gated']);
430+
});
431+
});

‎packages/objectql/src/engine-nested-plugin-collections.test.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -196,7 +196,11 @@ describe('the two registration seams enumerate ONE collection list (#7049)', ()
196196

197197
it.each(CANDIDATES)('registers `%s` identically from a manifest and from a nested plugin', (plural) => {
198198
const singular = pluralToSingular(plural);
199-
const item = { name: `probe_${plural}`, label: `Probe ${plural}` };
199+
// [#25649] An action is stored as its `ActionSchema` parse, so the probe
200+
// carries the one thing a `script` default needs beyond a name and label.
201+
const item = plural === 'actions'
202+
? { name: `probe_${plural}`, label: `Probe ${plural}`, type: 'url', target: 'https://example.com/probe' }
203+
: { name: `probe_${plural}`, label: `Probe ${plural}` };
200204

201205
const direct = new ObjectQL();
202206
direct.registerApp({ id: PKG, name: 'billing', [plural]: [item] });

‎packages/objectql/src/package-disable-enforcement.test.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -235,7 +235,12 @@ async function seed(protocol: any, registry: SchemaRegistry) {
235235
// [#24210] One item of every declared type, as a code package ships it:
236236
// the registry layer, stamped with the package that registered it.
237237
for (const type of BY_NAME_TYPES) {
238-
registry.registerItem(type, { name: shippedName(type, pkg), label: 'Shipped' } as any, 'name', pkg);
238+
// [#25649] A package's action is stored as its `ActionSchema` parse, so it
239+
// is shipped as a declaration that parses.
240+
const body = type === 'action'
241+
? { name: shippedName(type, pkg), label: 'Shipped', type: 'url', target: 'https://example.com/shipped' }
242+
: { name: shippedName(type, pkg), label: 'Shipped' };
243+
registry.registerItem(type, body as any, 'name', pkg);
239244
}
240245
}
241246
}

‎packages/objectql/src/registry-package-version-stamp.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ describe('registry path stamps the package version it is handed', () => {
7373
const ql = new ObjectQL();
7474
ql.registerApp({
7575
...manifest('2.0.0'),
76-
actions: [{ name: 'hc_convert', label: 'Convert', type: 'script' }],
76+
actions: [{ name: 'hc_convert', label: 'Convert', type: 'script', target: 'hc_convert_lead' }],
7777
plugins: [{ name: 'hc_notes', objects: [{ name: 'hc_note', label: 'Note', sharingModel: 'private', fields: { body: { name: 'body', type: 'text', label: 'Body' } } }] }],
7878
});
7979

‎packages/objectql/src/registry.test.ts‎

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -453,19 +453,21 @@ describe('SchemaRegistry', () => {
453453
// Generic Metadata Tests (Non-Object)
454454
// ==========================================
455455
describe('Generic Metadata', () => {
456+
// [#25649] `report`, not `action`: a package's action is no longer a
457+
// generic item — it is stored as its `ActionSchema` parse.
456458
it('should register and retrieve generic items', () => {
457-
const item = { name: 'test_action', type: 'custom' };
458-
registry.registerItem('action', item, 'name', 'com.pkg');
459+
const item = { name: 'test_report', type: 'custom' };
460+
registry.registerItem('report', item, 'name', 'com.pkg');
459461

460-
const retrieved = registry.getItem('action', 'test_action');
462+
const retrieved = registry.getItem('report', 'test_report');
461463
expect(retrieved).toEqual(item);
462464
});
463465

464466
it('should list items by type with package filter', () => {
465-
registry.registerItem('action', { name: 'a1' }, 'name', 'com.pkg1');
466-
registry.registerItem('action', { name: 'a2' }, 'name', 'com.pkg2');
467+
registry.registerItem('report', { name: 'a1' }, 'name', 'com.pkg1');
468+
registry.registerItem('report', { name: 'a2' }, 'name', 'com.pkg2');
467469

468-
const filtered = registry.listItems('action', 'com.pkg1');
470+
const filtered = registry.listItems('report', 'com.pkg1');
469471
expect(filtered).toHaveLength(1);
470472
});
471473
});

‎packages/objectql/src/registry.ts‎

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ import { postureEnforcesWall } from '@objectstack/spec/security';
4747
import { provisionSearchCompanion, SEARCH_COMPANION_FIELD } from './search-companion.js';
4848
import { ObjectStackManifest, ManifestSchema, InstalledPackage, InstalledPackageSchema, InstalledPackageSourceSchema, type InstalledPackageSource, checkFieldCompleteness } from '@objectstack/spec/kernel';
4949
import { DEFAULT_FLOW_FUNCTION_EFFECT } from '@objectstack/spec/automation';
50-
import { AppSchema } from '@objectstack/spec/ui';
50+
import { AppSchema, ActionSchema } from '@objectstack/spec/ui';
5151
import { applyProtection } from '@objectstack/spec/shared';
5252
// [ADR-0130 D1] The ONE derivation of the key a package is installed under
5353
// (`id || name`). The install gate's co-ownership set must name packages by the
@@ -153,6 +153,71 @@ export function describeUnboundActionTargets(
153153
return err;
154154
}
155155

156+
/**
157+
* [#25649] The refusal of a code package's `action` that does not satisfy
158+
* `ActionSchema` — the `INVALID_METADATA` envelope (ADR-0112) the unbound-target
159+
* refusal above answers, with `issues[]` locating each failure.
160+
*/
161+
interface PackageActionSchemaError extends Error {
162+
code: 'INVALID_METADATA';
163+
status: 422;
164+
httpStatus: 422;
165+
issues: Array<{ path: string; message: string; code: string }>;
166+
}
167+
168+
/**
169+
* [#25649] A code package's `action` item, as its `ActionSchema` parse — the
170+
* body {@link SchemaRegistry.registerItem} stores — or the loud refusal of one
171+
* that does not parse.
172+
*
173+
* Why the stored item has to BE the parse: `ActionSchema` is not only a judge,
174+
* it is a producer. Its transform lowers `requiresMembershipReach` and
175+
* `requiresFeature` into the CEL `visible` predicate and deletes the two keys,
176+
* and nothing downstream reads either key raw — so an action stored as authored
177+
* carries both gates declared and inert. A platform object's actions are parsed
178+
* where they are authored (`ObjectSchema.create`), and a strict `defineStack`
179+
* parses a stack's actions; a programmatic `registerApp`, a nested plugin's
180+
* manifest, a `strict: false` stack or a raw plugin manifest reached this store
181+
* with no parse at all, and {@link SchemaRegistry.attachBoundActions} served
182+
* what it found. Parsing at this seam, once, means every reader — the bound
183+
* attachment, the router's rung 2, `GET /meta/action` — holds the one parsed
184+
* body, by reference.
185+
*
186+
* The parse is idempotent over its own output (a lowered action carries no
187+
* `requiresFeature` left to lower), so an action a strict `defineStack` already
188+
* parsed is stored deep-equal to what it arrived as. Besides the lowering, the
189+
* parse fills the schema's defaults (`type`, `refreshAfter`, …), turns a
190+
* CEL-string shorthand into its `{ dialect, source }` envelope, and refuses an
191+
* undeclared key; the ADR-0010 envelope (`_packageId`, `_packageVersion`,
192+
* `_provenance`, `_lock`) is declared on the schema and passes through.
193+
*
194+
* Refused rather than stored unparsed: an action that fails the parse would be
195+
* served with whatever gates it declares never taking effect, and `defineStack`
196+
* and `os validate` refuse the same declaration at authoring time.
197+
*/
198+
function parsePackageAction(item: unknown, name: string, packageId: string): unknown {
199+
const parsed = ActionSchema.safeParse(item);
200+
if (parsed.success) return parsed.data;
201+
const issues = parsed.error.issues.map((issue) => ({
202+
path: issue.path.map(String).join('.'),
203+
message: issue.message,
204+
code: issue.code,
205+
}));
206+
const err = new Error(
207+
`Action '${name}' of package '${packageId}' does not satisfy the action contract (ActionSchema): `
208+
+ issues.map((issue) => (issue.path ? `${issue.path}: ${issue.message}` : issue.message)).join('; ')
209+
+ '. Every action a package ships is stored as its schema parse — the parse that lowers '
210+
+ '`requiresFeature` and `requiresMembershipReach` into `visible` — so an action that does not parse '
211+
+ 'is refused rather than served with its declared gates inert. Correct the declaration; `defineStack` '
212+
+ 'and `os validate` report the same issues.',
213+
) as PackageActionSchemaError;
214+
err.code = 'INVALID_METADATA';
215+
err.status = 422;
216+
err.httpStatus = 422;
217+
err.issues = issues;
218+
throw err;
219+
}
220+
156221
/**
157222
* [#24472] The key {@link SchemaRegistry}'s bound-action collision audit is
158223
* carried under. A symbol, and ⛔ deliberately NOT re-exported from the package
@@ -5160,6 +5225,18 @@ export class SchemaRegistry {
51605225
// package envelope. Every other caller gets the id alone.
51615226
applyProtection(item as any, { packageId });
51625227

5228+
// [#25649] A code package's action is stored as its `ActionSchema` parse —
5229+
// `requiresFeature` / `requiresMembershipReach` lowered into `visible` —
5230+
// or refused before anything below stores it ({@link parsePackageAction}).
5231+
// After `applyProtection`, so the envelope the parse passes through is the
5232+
// one this call stamped, and `isCodeArtifactBody` reads the stamped
5233+
// provenance. ⛔ A registration with no `packageId` is the bare slot —
5234+
// `sys_metadata` hydration, the metadata write-through — and is never
5235+
// judged here: a stored row is parsed at the metadata save door.
5236+
if (type === 'action' && packageId && isCodeArtifactBody(item)) {
5237+
item = parsePackageAction(item, baseName, packageId) as T;
5238+
}
5239+
51635240
// A picklist's own values must not repeat any value its extensions
51645241
// already add — refused before anything is stored, like the extension
51655242
// side ({@link registerPicklistExtension}). Every resolved object depends

‎packages/runtime/src/domains/mcp-package-object-discovery.test.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -581,7 +581,9 @@ function actionPackageOf(prefix: string) {
581581
type: 'script',
582582
operation: 'update',
583583
patch: { name: 'done' },
584-
...(exposed ? { ai: { exposed: true, description: `${name} for the test` } } : {}),
584+
// [#25649] A package's top-level action is stored as its `ActionSchema`
585+
// parse, which holds an AI-exposed action's `description` to 40 characters.
586+
...(exposed ? { ai: { exposed: true, description: `${name}: marks the record done, for the MCP discovery test` } } : {}),
585587
...extra,
586588
});
587589
return {

0 commit comments

Comments
 (0)