Skip to content

Commit a4eae06

Browse files
fix(objectql)!: store an object's own actions as their ActionSchema parse, so a raw manifest's requiresFeature is lowered (#25981)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1X2VRuXMiLAGHgLpSEE4E55n)_ Fixes #25744 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 **BREAKING** for a package that registers an object whose own action `ActionSchema` does not accept. Such an object used to be stored as written, with the action's declared gates 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[]` locates each failure as `actions.N.KEY`. An object that passes `os validate` or a strict `defineStack` is unaffected. ADR-0087 disposition (in the changeset): `not-required (no-migration-prescription)`. `ActionSchema` and `ObjectSchema` are byte-unchanged, so there is nothing for `objectstack migrate meta` to rewrite. ## What changed `SchemaRegistry.registerObject` (`packages/objectql/src/registry.ts`) now stores a code package's object with its own `actions` as their `ActionSchema` parse. If an action does not parse, the registry refuses the object loudly. Before, the actions were stored as the package wrote them. - **One parse path.** The object path reuses the helper PR #25699 added for a standalone `action` item, `parsePackageAction`. It gains an optional `owner` (`{ object, index }`), so an object action's refusal names its object and prefixes each issue path with `actions.N`. A new `parsePackageObjectActions` maps it over the object's `actions` and returns a shallow copy; the caller's body is never mutated. - **Where in the method.** Right after the field-`type` door and the two `extend` refusals, and before any materialization or the contributor list. So a refused object leaves nothing behind, and a refused re-registration leaves the object already registered under that package in place (pinned). - **Which registrations.** The test is `registerItem`'s action-parse test (`packageId` passed and `isCodeArtifactBody`), asked of the body as `applyProtection` will stamp it later in the method: the package id it does not already carry, and `'package'` provenance. A registration with no `packageId`, the `sys_metadata` sentinel, and a tenant-authored body (`_provenance: 'org'`: the save door's write-through and the boot rehydration) are not judged here. - **Idempotent over parsed input.** `ObjectSchema`'s `actions` is `ActionSchema[]`, so a platform object (`ObjectSchema.create`) and a strict `defineStack` already hand this method parsed actions, and they are stored deep-equal to what arrived (pinned as the control). - **Refusal envelope.** `INVALID_METADATA` / 422 (ADR-0112), the code this package already uses for metadata that fails the spec. No new error code. ## Producers measured Measured at `9ca36b163e` with every in-repo producer of an object with `actions`: each object's actions run through `ActionSchema.safeParse` and compared as canonical (key-sorted) JSON. Sources: every non-test file under `packages/**/src` that declares an `actions: [` array (connectors, platform objects, plugin-approvals, service-automation, qa fixtures, verify), the four example stacks (`app-crm`, `app-todo`, `app-showcase`, `app-multi-package`) and `platform-objects`' entry. - 21 objects carrying actions, 154 actions. Refused: 4, all on one object. Changed by the parse: 0. - The 4 refused are the `account` object of the published test fixture `FLS_CONTRACT_OBJECT` (`@objectstack/metadata-core/testing`): its `script` actions carry no `body` or `target`. Its own CHANGELOG already records it as not a spec-valid object. No suite registers it through `registerObject` (its readers in `packages/rest` and `packages/metadata-core` serve it from a stored map), so this door never meets it. It is left unchanged and named in the acceptance notes. - No shipped product object fails the parse. ### Fixture triage (the refusal's consumer radius) Two runtime fixtures registered an object action body `ActionSchema` refuses. Each got the missing declaration; nothing pinned the removed behaviour: - `runtime/src/stored-metadata-reader-contexts.pin.test.ts`: the four host-handler `script` actions on `pin_note` name their `target`. Dispatch is by handler key, so the pins' subject is unchanged (171 tests in the two files green). - `runtime/src/domains/mcp-package-object-discovery.test.ts`: the AI-exposed `ping` action's `description` is 40 characters or more, the length `ActionSchema` requires. ## Tests New, `packages/objectql/src/object-own-action-schema-parse.test.ts`, through the real `ObjectQL.registerApp` / `registry.registerObject` and `protocol.getMetaItem({ type: 'object' })`, the read behind `GET /api/v1/meta/objects/:name`: - A raw manifest's object action with `requiresFeature` and a CEL `visible` is served with the conjunct lowered, with no `requiresFeature` key, equal to what `ObjectSchema.create` gives the same declaration. - With no `visible`, the gate alone. `requiresMembershipReach` is lowered the same way. - A `strict: false` `defineStack` hands the body on raw; registered, it is served lowered. - A direct `registerObject` under a package is parsed, and the caller's body is not mutated. - Control: an object `ObjectSchema.create` produced is served deep-equal to it. - Refusal: `requiresFeature` beside `visible: false` is refused `INVALID_METADATA` / 422; the message names `gated`, `oa_org` and `com.test.raw`; `issues[]` holds `actions.0.requiresFeature`; nothing is registered. - A refused re-registration (an undeclared key, `unrecognized_keys`) leaves the registered object in place. **Red first.** With `registry.ts` restored to the base blob, the new file ran 7 failed / 1 passed; the one green was the control. **Ablation** (`scripts/ablation-replace.mjs`). The test imports `./registry.js` from source, so no dist leg applies. The anchor `schema = parsePackageObjectActions(schema, packageId);` hit once and was replaced by a comment; blob `ca946b02a9` to `b5fb1f4f77`. Result: 7 failed, 1 passed (the control). Restore: blob `ca946b02a9` equals HEAD and `git diff HEAD` is empty. ## Local verification Head `cf6bf094b9`. - **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 296 passed | 3 skipped (299)`, `Tests 2523 passed | 18 skipped (2541)`, `VERDICT command-exit 0`. Run at `3867ab77`; the only later commit edits two runtime test fixtures. - `@objectstack/objectql` full: `Test Files 470 passed (470)`, `Tests 8738 passed (8738)`. Typecheck exit 0, `check:test-typecheck: OK`. - `@objectstack/runtime` full: `Test Files 446 passed (446)`, `Tests 7131 passed | 19 skipped`. Typecheck exit 0, `check:test-typecheck: OK`. - `@objectstack/rest` full: `Test Files 322 passed (322)`, `Tests 6466 passed | 326 skipped`. - `@objectstack/metadata-protocol` full: `Test Files 264 passed | 3 skipped (267)`, `Tests 21518 passed | 19 skipped`. - `eslint --no-inline-config` over the touched `objectql` files: exit 0. - `dispatch-gates.mjs --commands` on this diff derives 73 families; all run, `--ran`: 73 derived, 73 run, 0 NOT-MEASURED. ## Acceptance notes - **Boundary kept: tenant overlay rows.** A tenant-authored object body (`_provenance: 'org'`) is not parsed at this door. It is the save door's to parse, and the overlay row of a packaged action is outside this card. - **A non-array `actions`.** Only an `actions` array is parsed. A body whose `actions` is not an array is left as it was; `ObjectSchema` refuses that shape at the authoring doors. - **`FLS_CONTRACT_OBJECT` stays off-contract.** Its four `script` actions carry no `body` or `target` (measured above). This door never registers it today; a suite that registers it under a package would now be refused. - **Doubled period in the refusal text.** `parsePackageAction` joins each issue's message and then adds a period, so a Zod message that ends in one reads `…function name)..`. The standalone-action refusal already prints this on `main`; this PR does not change the sentence. --- _Generated by [Claude Code](https://claude.ai/code/session_012FM5zuP45LkwYGZfazCj5z)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent f36f619 commit a4eae06

5 files changed

Lines changed: 265 additions & 8 deletions

File tree

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)!: an object's own actions are stored as their `ActionSchema` parse, so `requiresFeature` takes effect on a raw manifest's object actions
6+
7+
Clause-②: no (narrowing: an object's own action that fails `ActionSchema` is refused at the runtime registration door, as #25649 did for standalone actions)
8+
9+
Metadata-protocol: none
10+
11+
<!-- 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). -->
12+
13+
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.
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 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.
22+
23+
**BREAKING** for a package that registers an object whose own action `ActionSchema` does not accept.
24+
25+
- **Before.** The registry stored such an object as written, and served the action with any declared gate inert.
26+
- **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.
27+
- **Fix.** Correct the action as `issues[]` describes. An object that passes `os validate`, or a strict `defineStack`, is unaffected.
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#25744] An object's OWN `actions`, registered by a code package through
5+
* {@link SchemaRegistry.registerObject}, are stored as their `ActionSchema`
6+
* parse — the parse a standalone `action` item is stored as since #25649 — so a
7+
* declared `requiresFeature` / `requiresMembershipReach` is lowered into
8+
* `visible`, and an action that does not parse is refused (`INVALID_METADATA`,
9+
* 422) before anything of the object is stored.
10+
*
11+
* Before, a raw manifest or a `strict: false` stack registered with
12+
* `ObjectQL.registerApp` stored its objects' actions as written: served by
13+
* `GET /api/v1/meta/objects/:name` (`protocol.getMetaItem({ type: 'object' })`)
14+
* with `requiresFeature` riding along raw and no `visible` — a feature gate no
15+
* reader enforces. A strict `defineStack`, `os validate` and the metadata save
16+
* door already parse the same body through `ActionSchema`.
17+
*/
18+
19+
import { describe, it, expect } from 'vitest';
20+
import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol';
21+
import { defineStack } from '@objectstack/spec';
22+
import { ObjectSchema } from '@objectstack/spec/data';
23+
import { SysMetadataAuditObject, SysMetadataCommitObject, SysMetadataHistoryObject } from '@objectstack/metadata-core';
24+
import { ObjectQL } from './engine.js';
25+
26+
/** One gated declaration, authored on an object's own action. */
27+
const GATED = {
28+
name: 'gated', label: 'Gated', type: 'url' as const, target: 'https://example.com/gated',
29+
locations: ['record_header' as const], visible: "record.name != ''",
30+
requiresFeature: 'multiOrgEnabled' as const,
31+
};
32+
const LOWERED = { dialect: 'cel', source: "(record.name != '') && features.multiOrgEnabled != false" };
33+
34+
const objectWith = (actions: unknown[]) => ({
35+
name: 'oa_org', label: 'Org',
36+
fields: { name: { name: 'name', type: 'text', label: 'Name' } },
37+
actions,
38+
});
39+
const rawManifest = (actions: unknown[]) => ({ id: 'com.test.raw', name: 'raw', objects: [objectWith(actions)] });
40+
41+
/** A driver that holds nothing: the reads below never reach a stored row. */
42+
const emptyDriver: any = {
43+
name: 'memory', version: '0.0.0', supports: {},
44+
async connect() {}, async disconnect() {}, async checkHealth() { return true; },
45+
async execute() { return null; },
46+
async find() { return []; }, async findOne() { return null; }, async count() { return 0; },
47+
};
48+
49+
/** The stored-metadata family the by-name read consults for an overlay row (none here). */
50+
const sysMetadataObject = {
51+
name: 'sys_metadata', label: 'System Metadata',
52+
fields: {
53+
id: { name: 'id', label: 'ID', type: 'text' as const, primaryKey: true },
54+
type: { name: 'type', label: 'Type', type: 'text' as const, required: true },
55+
name: { name: 'name', label: 'Name', type: 'text' as const, required: true },
56+
organization_id: { name: 'organization_id', label: 'Org', type: 'text' as const },
57+
package_id: { name: 'package_id', label: 'Package', type: 'text' as const },
58+
metadata: { name: 'metadata', label: 'Body', type: 'textarea' as const },
59+
state: { name: 'state', label: 'State', type: 'text' as const },
60+
},
61+
};
62+
63+
async function host() {
64+
const engine = new ObjectQL();
65+
engine.registerDriver(emptyDriver, true);
66+
await engine.init();
67+
engine.registry.registerObject(sysMetadataObject);
68+
for (const o of [SysMetadataHistoryObject, SysMetadataAuditObject, SysMetadataCommitObject]) engine.registry.registerObject(o as any);
69+
return { engine, protocol: new ObjectStackProtocolImplementation(engine) };
70+
}
71+
72+
const servedAction = async (protocol: ObjectStackProtocolImplementation, name: string) => {
73+
const served: any = await protocol.getMetaItem({ type: 'object', name: 'oa_org' } as any);
74+
return (served.item.actions as any[]).find((a) => a?.name === name);
75+
};
76+
77+
const refusalOf = (fn: () => unknown): any => {
78+
try { fn(); } catch (e) { return e; }
79+
return undefined;
80+
};
81+
82+
describe("an object's own action is stored as its ActionSchema parse", () => {
83+
it('a raw manifest: GET /meta/object/<name> serves `requiresFeature` lowered into `visible`', async () => {
84+
const { engine, protocol } = await host();
85+
engine.registerApp(rawManifest([GATED]));
86+
const served = await servedAction(protocol, 'gated');
87+
expect(served.visible).toEqual(LOWERED);
88+
expect(served).not.toHaveProperty('requiresFeature');
89+
// The same lowering `ObjectSchema.create` gives a platform object's action of the same declaration.
90+
const authored: any = ObjectSchema.create(objectWith([GATED]) as any);
91+
expect(served.visible).toEqual(authored.actions[0].visible);
92+
// The registry's own resolved object holds the same parsed body.
93+
expect(engine.registry.getObject('oa_org')?.actions?.[0]).toEqual(served);
94+
});
95+
96+
it('an action with no `visible` of its own is served the gate alone', async () => {
97+
const { visible: _visible, ...ungated } = GATED;
98+
const { engine, protocol } = await host();
99+
engine.registerApp(rawManifest([ungated]));
100+
const served = await servedAction(protocol, 'gated');
101+
expect(served.visible).toEqual({ dialect: 'cel', source: 'features.multiOrgEnabled != false' });
102+
expect(served).not.toHaveProperty('requiresFeature');
103+
});
104+
105+
it('`requiresMembershipReach` is lowered the same way', async () => {
106+
const { requiresFeature: _feature, ...rest } = GATED;
107+
const { engine, protocol } = await host();
108+
engine.registerApp(rawManifest([{ ...rest, requiresMembershipReach: 'invite_member' }]));
109+
const served = await servedAction(protocol, 'gated');
110+
expect(served).not.toHaveProperty('requiresMembershipReach');
111+
expect(served.visible).toEqual(ObjectSchema.create(objectWith([{ ...rest, requiresMembershipReach: 'invite_member' }]) as any).actions![0].visible);
112+
});
113+
114+
it('a `strict: false` stack registered with registerApp is parsed at the registry', async () => {
115+
const stack: any = defineStack({
116+
manifest: { id: 'com.test.loose', name: 'loose', version: '1.0.0', type: 'app' },
117+
objects: [objectWith([GATED])],
118+
} as any, { strict: false });
119+
// The loose stack hands the body on as written.
120+
expect(stack.objects[0].actions[0]).toHaveProperty('requiresFeature');
121+
const { engine, protocol } = await host();
122+
engine.registerApp({ ...stack, ...stack.manifest });
123+
const served = await servedAction(protocol, 'gated');
124+
expect(served.visible).toEqual(LOWERED);
125+
expect(served).not.toHaveProperty('requiresFeature');
126+
});
127+
128+
it('a direct registerObject under a package is parsed; the caller\'s body is not mutated', async () => {
129+
const { engine } = await host();
130+
const def = objectWith([structuredClone(GATED)]);
131+
engine.registry.registerObject(def as any, 'com.test.direct');
132+
expect(engine.registry.getObject('oa_org')?.actions?.[0]).toMatchObject({ visible: LOWERED });
133+
expect(def.actions[0]).toHaveProperty('requiresFeature', 'multiOrgEnabled');
134+
});
135+
136+
it('control: an object a strict parse already produced is stored deep-equal to what it arrived as', async () => {
137+
const authored: any = ObjectSchema.create(objectWith([GATED]) as any);
138+
const { engine, protocol } = await host();
139+
engine.registerApp({ id: 'com.test.strict', name: 'strict', objects: [structuredClone(authored)] });
140+
expect(await servedAction(protocol, 'gated')).toEqual(authored.actions[0]);
141+
});
142+
143+
it('an action that does not parse is refused loudly, naming the action, its object and package — nothing stored', async () => {
144+
const { engine } = await host();
145+
const err = refusalOf(() => engine.registerApp(rawManifest([{ ...GATED, visible: false }])));
146+
expect(err).toMatchObject({ code: 'INVALID_METADATA', status: 422 });
147+
expect(err.message).toContain("Action 'gated' of object 'oa_org' in package 'com.test.raw' does not satisfy the action contract");
148+
expect(err.issues).toEqual(expect.arrayContaining([expect.objectContaining({ path: 'actions.0.requiresFeature' })]));
149+
expect(engine.registry.getObject('oa_org')).toBeUndefined();
150+
});
151+
152+
it('a refused re-registration leaves the object already registered under that package in place', async () => {
153+
const { engine } = await host();
154+
engine.registerApp(rawManifest([GATED]));
155+
const err = refusalOf(() => engine.registry.registerObject(objectWith([{ ...GATED, bogus: true }]) as any, 'com.test.raw'));
156+
expect(err).toMatchObject({ code: 'INVALID_METADATA', status: 422 });
157+
expect(err.issues).toEqual(expect.arrayContaining([expect.objectContaining({ code: 'unrecognized_keys' })]));
158+
expect(engine.registry.getObject('oa_org')?.actions?.[0]).toMatchObject({ name: 'gated', visible: LOWERED });
159+
});
160+
});

‎packages/objectql/src/registry.ts‎

Lines changed: 69 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -194,17 +194,35 @@ interface PackageActionSchemaError extends Error {
194194
* Refused rather than stored unparsed: an action that fails the parse would be
195195
* served with whatever gates it declares never taking effect, and `defineStack`
196196
* and `os validate` refuse the same declaration at authoring time.
197+
*
198+
* [#25744] The one parse for both places a code package declares an action:
199+
* a standalone `action` item ({@link SchemaRegistry.registerItem}) and an
200+
* object's own `actions` ({@link parsePackageObjectActions}, called by
201+
* {@link SchemaRegistry.registerObject}). `owner` names the object an own
202+
* action is declared on, so the refusal says which object and `issues[]`
203+
* locates each failure inside its body.
197204
*/
198-
function parsePackageAction(item: unknown, name: string, packageId: string): unknown {
205+
function parsePackageAction(
206+
item: unknown,
207+
name: string,
208+
packageId: string,
209+
owner?: { object: string; index: number },
210+
): unknown {
199211
const parsed = ActionSchema.safeParse(item);
200212
if (parsed.success) return parsed.data;
213+
// [#25744] An object's own action is located inside the object body it was
214+
// declared in (`actions.<index>.<key>`), the path `ObjectSchema` reports.
215+
const prefix = owner ? ['actions', String(owner.index)] : [];
201216
const issues = parsed.error.issues.map((issue) => ({
202-
path: issue.path.map(String).join('.'),
217+
path: [...prefix, ...issue.path.map(String)].join('.'),
203218
message: issue.message,
204219
code: issue.code,
205220
}));
221+
const subject = owner
222+
? `Action '${name}' of object '${owner.object}' in package '${packageId}'`
223+
: `Action '${name}' of package '${packageId}'`;
206224
const err = new Error(
207-
`Action '${name}' of package '${packageId}' does not satisfy the action contract (ActionSchema): `
225+
`${subject} does not satisfy the action contract (ActionSchema): `
208226
+ issues.map((issue) => (issue.path ? `${issue.path}: ${issue.message}` : issue.message)).join('; ')
209227
+ '. Every action a package ships is stored as its schema parse — the parse that lowers '
210228
+ '`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
218236
throw err;
219237
}
220238

239+
/**
240+
* [#25744] A code package's object, its own `actions` stored as their
241+
* `ActionSchema` parse ({@link parsePackageAction}) — or the loud refusal of
242+
* the first one that does not parse.
243+
*
244+
* The object-path twin of {@link SchemaRegistry.registerItem}'s action parse.
245+
* `ObjectSchema`'s `actions` is `ActionSchema[]`, so a platform object
246+
* (`ObjectSchema.create`) and a strict `defineStack` already hand this method
247+
* parsed actions, and the parse being idempotent over its own output stores
248+
* them deep-equal to what arrived. A raw manifest, a `strict: false` stack or a
249+
* programmatic `registerApp` reached the store with its objects' actions as
250+
* written, so a declared `requiresFeature` / `requiresMembershipReach` was
251+
* served raw, never lowered into `visible`.
252+
*
253+
* Returns `schema` by reference when it declares no `actions` array, and a
254+
* shallow copy carrying the parsed array otherwise: the caller's body is
255+
* never mutated.
256+
*/
257+
function parsePackageObjectActions(schema: ServiceObject, packageId: string): ServiceObject {
258+
const actions = (schema as { actions?: unknown }).actions;
259+
if (!Array.isArray(actions) || actions.length === 0) return schema;
260+
const parsed = actions.map((action, index) => {
261+
const declared = (action as { name?: unknown } | null | undefined)?.name;
262+
const name = typeof declared === 'string' && declared !== '' ? declared : `#${index}`;
263+
return parsePackageAction(action, name, packageId, { object: schema.name, index });
264+
});
265+
return { ...schema, actions: parsed } as ServiceObject;
266+
}
267+
221268
/**
222269
* [#24472] The key {@link SchemaRegistry}'s bound-action collision audit is
223270
* carried under. A symbol, and ⛔ deliberately NOT re-exported from the package
@@ -3442,6 +3489,25 @@ export class SchemaRegistry {
34423489
throw new ExtensionMembershipKeysRefusedError(schema.name, packageId);
34433490
}
34443491

3492+
// [#25744] A code package's object keeps its own actions as their
3493+
// `ActionSchema` parse — `requiresFeature` / `requiresMembershipReach`
3494+
// lowered into `visible` — or is refused here, beside the doors above and
3495+
// for their reason: before any materialization and before the contributor
3496+
// list is opened, so a refused object leaves nothing behind and a refused
3497+
// re-registration leaves the registered one in place
3498+
// ({@link parsePackageObjectActions}). The test is `registerItem`'s action
3499+
// parse's, asked of the body as `applyProtection` below will stamp it (a
3500+
// package id it does not already carry, `'package'` provenance): ⛔ a
3501+
// registration with no `packageId`, the `sys_metadata` sentinel, or a
3502+
// tenant-authored body (`_provenance: 'org'` — the save door's write-through
3503+
// and the boot rehydration) is never judged here.
3504+
if (packageId && isCodeArtifactBody({
3505+
_packageId: (schema as { _packageId?: unknown })._packageId ?? packageId,
3506+
_provenance: (schema as { _provenance?: unknown })._provenance ?? 'package',
3507+
})) {
3508+
schema = parsePackageObjectActions(schema, packageId);
3509+
}
3510+
34453511
// [#23065] A `tree` field that names no `reference` references the object
34463512
// that declares it, and this is where that is written down for every door
34473513
// the parse does not cover: a metadata save stores the authored body

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -594,7 +594,8 @@ function actionPackageOf(prefix: string) {
594594
const base: any = pkg.artifact();
595595
base.objects[0].actions = [
596596
update('mark_done', true),
597-
{ name: 'ping', label: 'Ping', type: 'script', target: 'qa_ping', ai: { exposed: true, description: 'Ping' } },
597+
// [#25744] An object's own action is stored as its `ActionSchema` parse too.
598+
{ name: 'ping', label: 'Ping', type: 'script', target: 'qa_ping', ai: { exposed: true, description: 'Ping: answers pong, for the MCP discovery test' } },
598599
update('hidden_done', false),
599600
];
600601
base.objects[1].actions = [update('gated_done', true)];

0 commit comments

Comments
 (0)