diff --git a/.changeset/code-only-metadata-types-refused-everywhere.md b/.changeset/code-only-metadata-types-refused-everywhere.md new file mode 100644 index 0000000000..6db18fd3b5 --- /dev/null +++ b/.changeset/code-only-metadata-types-refused-everywhere.md @@ -0,0 +1,68 @@ +--- +"@objectstack/metadata-protocol": patch +--- + +fix(metadata-protocol): `allowRuntimeCreate: false` is enforced on every kernel — `PUT /meta` no longer creates `job` / `agent` items the registry declares code-only (#5086) + +#4509 set `allowRuntimeCreate: false` on `job` and promised the refusal without +qualification — *no "create job" in Studio or via `PUT /meta`*. ADR-0063 §2 says +the same for `agent`. The gate that keeps that promise existed, and worked, but +it sat behind `environmentId !== undefined`: + +```ts +if (this.environmentId !== undefined) { + // …not_overridable / not_creatable… +} +``` + +`environmentId` is a **row-scoping key**, not an authorization signal. Every +kernel assembled without one ran with the entire ADR-0005 authorization gate +disengaged — and that is not an exotic topology. The CLI's lightweight +assembler builds exactly that for a host config (`isHostConfig` → the +`createStandaloneStack` branch is skipped → `new ObjectQLPlugin()` with no +`environmentId`), which is the flagship showcase and every self-hosted app +server shaped like it. On those, the issue's repro answered: + +``` +PUT /api/v1/meta/job/rc3_runtime_job + {"name":"rc3_runtime_job","label":"J", + "schedule":{"type":"cron","expression":"0 0 * * *"},"handler":"nope"} +→ 200 {"success":true,"message":"Saved customization overlay (env-wide) — type=job, …"} +``` + +`handler: "nope"` names no function in any compiled bundle. The row persists, +lists, and can never be scheduled — the record #4509 exists to prevent, saved +and reported as success. It is the ADR-0049 failure mode one level up: the +*enforcement flag itself* was the silently-inert declaration, and Studio (which +reads the flag to hide "create") honoured a rule the API underneath did not. + +**What changed.** A type whose registry entry sets BOTH `allowRuntimeCreate: +false` AND `allowOrgOverride: false` declares that it has no runtime write +channel at all. `saveMetaItem` now refuses it on every kernel, before +persistence, in draft mode as well as publish: + +| write | before | now | +|---|---|---| +| `PUT /meta/job/*` on a single-kernel host | `200 success` | `403 NOT_CREATABLE` | +| `PUT /meta/agent/*` on a single-kernel host | `200 success` | `403 NOT_CREATABLE` | +| same, over a name a code package ships | `200 success` | `403 NOT_OVERRIDABLE` | +| project-scoped (cloud) kernels | `403` | `403` (unchanged) | + +The refusal names the type, the flags that produced the verdict, the source +file pattern to declare it in (read from the type's own registry entry, so a +newly-flagged type carries an accurate hint the day it is flagged) and the +`OS_METADATA_WRITABLE` escape hatch. + +**Scope, deliberately.** The rest of the ADR-0005 two-tier gate keeps its +single-kernel carve-out: that ADR's "single-kernel deployments keep their +existing behaviour" sentence is about the *overlay whitelist*, predates +`allowRuntimeCreate` entirely, and a type that stays runtime-creatable +(`object`, `hook`, `field`, `seed`, `mapping`, …) is untouched here. So is +`deleteMetaItem` — removing a code-only row that predates this refusal is +repair and must stay possible. `OS_METADATA_WRITABLE` remains the one door: +unlocking a type there unlocks it here too. + +**Upgrading.** If a deployment relies on runtime-created `job` or `agent` rows, +move them into source (`**/*.job.ts`, `**/*.agent.ts`) and redeploy — a `job` +authored at runtime never had a reachable `handler` in the first place. To keep +writing them while migrating, set `OS_METADATA_WRITABLE=job,agent`. diff --git a/packages/metadata-protocol/src/protocol.code-only-types.test.ts b/packages/metadata-protocol/src/protocol.code-only-types.test.ts new file mode 100644 index 0000000000..684bc69d08 --- /dev/null +++ b/packages/metadata-protocol/src/protocol.code-only-types.test.ts @@ -0,0 +1,324 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #5086 — `allowRuntimeCreate: false` is enforced on EVERY kernel. + * + * #4509 set `allowRuntimeCreate: false` on `job` and its changeset promised + * the refusal without qualification — *no "create job" in Studio or via + * `PUT /meta`*. ADR-0063 §2 says the same for `agent`. The gate that keeps + * that promise sat behind `environmentId !== undefined`, so it ran on + * project-scoped (cloud per-env) kernels only. Every kernel assembled + * WITHOUT an environmentId — which is what the CLI's lightweight assembler + * builds for a host config (`isHostConfig` → `shouldBootWithLibrary` false → + * `new ObjectQLPlugin()`), i.e. the flagship showcase and every self-hosted + * app server shaped like it — accepted the write and answered + * `200 {"success":true,"message":"Saved customization overlay (env-wide) …"}` + * for a `job` whose `handler` names no function in any compiled bundle. + * + * So these tests run every case against BOTH kernel shapes. The unscoped one + * is the regression; the scoped one pins the behaviour that already worked so + * the two topologies can never drift apart again. + * + * The flags are DATA (`DEFAULT_METADATA_TYPE_REGISTRY`), so the suite is + * data-driven: it derives the code-only set from the registry and fails when + * a newly-flagged type arrives without a probe payload here. Covering the + * next flagged type costs one entry in {@link PROBES}. + * + * Harness: the real write path over a stub engine (same shape as + * `protocol.runtime-authoring-gate.test.ts`) — a gate INSIDE `saveMetaItem` + * cannot be tested against a harness that mocks `saveMetaItem`. + */ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { DEFAULT_METADATA_TYPE_REGISTRY } from '@objectstack/spec/kernel'; +import { ObjectStackProtocolImplementation } from './protocol.js'; +import { resetEnvWritableMetadataTypes } from './sys-metadata-repository.js'; + +interface Row { + id: string; + type: string; + name: string; + organization_id: string | null; + state: string; + metadata: string; +} + +/** + * A type is code-only when the registry gives it NO runtime write channel at + * all. Derived, never hardcoded — Prime Directive #8 ("no parallel + * whitelists"): if someone flags a third type, this set grows by itself and + * the coverage guard below turns red until it has a probe. + */ +const CODE_ONLY_TYPES = DEFAULT_METADATA_TYPE_REGISTRY + .filter((e) => !e.allowRuntimeCreate && !e.allowOrgOverride) + .map((e) => e.type); + +/** + * Schema-VALID bodies, straight from the issue's repro. This matters: a + * minimal payload 422s on spec validation first, which is exactly what made + * the missing gate easy to miss — only a body the schema accepts proves the + * refusal came from the registry consult. + */ +const PROBES: Record }> = { + job: { + name: 'rc3_runtime_job', + item: { + name: 'rc3_runtime_job', + label: 'J', + schedule: { type: 'cron', expression: '0 0 * * *' }, + handler: 'nope', + }, + }, + agent: { + name: 'rc3_agent_probe', + item: { + name: 'rc3_agent_probe', + label: 'A', + role: 'assistant', + instructions: 'be helpful', + }, + }, +}; + +function makeStubEngine(artifacts: Array<{ type: string; name: string }> = []) { + const rows = new Map(); + let nextId = 0; + const artifactKeys = new Set(artifacts.map((a) => `${a.type}|${a.name}`)); + const keyOf = (w: Record) => + `${w.type}|${w.name}|${w.organization_id ?? '__env__'}|${w.state ?? 'active'}`; + const engine: any = { + async findOne(_t: string, opts: { where: Record }) { + for (const row of rows.values()) { + if (opts.where.type !== undefined && row.type !== opts.where.type) continue; + if (opts.where.name !== undefined && row.name !== opts.where.name) continue; + return row; + } + return null; + }, + async find() { return []; }, + async insert(_t: string, data: Record) { + if (_t !== 'sys_metadata') return { id: 'side_effect_skip' }; + nextId += 1; + const row = { id: `r_${nextId}`, ...(data as any) } as Row; + rows.set(keyOf(data), row); + return { id: row.id }; + }, + async update() { return { id: null }; }, + async delete() { return { deleted: 0 }; }, + registry: { + registerItem: () => {}, + registerObject: () => {}, + listItems: () => [], + getItem: () => undefined, + // `isArtifactBacked` prefers this lookup — a hit here means the + // name is shipped by a code package (`_packageId` provenance). + getArtifactItem: (type: string, name: string) => + artifactKeys.has(`${type}|${name}`) ? { name, _packageId: 'showcase' } : undefined, + }, + }; + return { engine, rows }; +} + +/** + * The two kernel shapes this issue is about. + * + * • `single-kernel` — `environmentId` undefined. The CLI lightweight + * assembler / showcase topology, where the gate never ran (the bug). + * • `project-kernel` — `environmentId` set. Cloud per-env kernels, where + * the gate already ran (the pin). + */ +const KERNELS: Array<{ label: string; environmentId?: string }> = [ + { label: 'single-kernel (no environmentId)' }, + { label: 'project-kernel (environmentId set)', environmentId: 'env_test' }, +]; + +function makeProtocol(environmentId?: string, artifacts?: Array<{ type: string; name: string }>) { + const { engine, rows } = makeStubEngine(artifacts); + const protocol = new ObjectStackProtocolImplementation( + engine, + () => new Map(), + environmentId, + ) as any; + return { protocol, rows }; +} + +const metaRows = (rows: Map) => Array.from(rows.values()); + +describe('code-only metadata types are refused on every kernel (#5086)', () => { + beforeEach(() => { + delete process.env.OS_METADATA_WRITABLE; + // Two memoised readers of the same env var — the protocol's gate and + // the repository's `assertAllowed`. Both must be reset or the second + // one answers from a stale parse. + ObjectStackProtocolImplementation.resetEnvWritableCache(); + resetEnvWritableMetadataTypes(); + }); + afterEach(() => { + delete process.env.OS_METADATA_WRITABLE; + // Two memoised readers of the same env var — the protocol's gate and + // the repository's `assertAllowed`. Both must be reset or the second + // one answers from a stale parse. + ObjectStackProtocolImplementation.resetEnvWritableCache(); + resetEnvWritableMetadataTypes(); + }); + + // ── the flags are data: keep the suite honest about new ones ────────── + + it('covers every code-only type the registry declares', () => { + // Today: job (#4509) and agent (ADR-0063 §2). When a third type is + // flagged, this fails until it has a schema-valid probe above — + // which is the whole cost of covering it. + expect(CODE_ONLY_TYPES.length).toBeGreaterThan(0); + expect([...CODE_ONLY_TYPES].sort()).toEqual(['agent', 'job']); + for (const type of CODE_ONLY_TYPES) { + expect(PROBES[type], `no probe payload for code-only type '${type}'`).toBeDefined(); + } + }); + + // ── the refusal, per flagged type, on both kernels ──────────────────── + + for (const { label, environmentId } of KERNELS) { + describe(label, () => { + for (const type of CODE_ONLY_TYPES) { + const probe = PROBES[type]!; + + it(`refuses a schema-valid ${type} create with the catalogued code`, async () => { + const { protocol, rows } = makeProtocol(environmentId); + + const err = await protocol + .saveMetaItem({ type, name: probe.name, item: probe.item }) + .then(() => null, (e: any) => e); + + expect(err, 'the write was accepted').not.toBeNull(); + expect(err.code).toBe('NOT_CREATABLE'); + expect(err.status).toBe(403); + // Names the type and why it is code-only — a refusal the + // author can act on without reading the source. + expect(err.message).toContain(`'${type}'`); + expect(err.message).toContain('allowRuntimeCreate=false'); + expect(err.message).toContain('code-only'); + + // A gate that refuses AFTER persisting is a log line. + expect(metaRows(rows)).toEqual([]); + }); + + it(`refuses an org-scoped ${type} create too`, async () => { + const { protocol, rows } = makeProtocol(environmentId); + const err = await protocol + .saveMetaItem({ + type, + name: probe.name, + item: probe.item, + organizationId: 'org_alpha', + }) + .then(() => null, (e: any) => e); + + expect(err?.code).toBe('NOT_CREATABLE'); + expect(metaRows(rows)).toEqual([]); + }); + + it(`refuses a ${type} DRAFT save (Studio's staging door)`, async () => { + // #4509 is explicit that Studio must not offer "create" at + // all — staging it as a draft first is the same create. + const { protocol, rows } = makeProtocol(environmentId); + const err = await protocol + .saveMetaItem({ type, name: probe.name, item: probe.item, mode: 'draft' }) + .then(() => null, (e: any) => e); + + expect(err?.code).toBe('NOT_CREATABLE'); + expect(metaRows(rows)).toEqual([]); + }); + + it(`refuses overlaying an artifact-backed ${type} with not_overridable`, async () => { + // Same verdict, honest reason: the name IS shipped by a + // code package, so "you may not overlay it" beats "you may + // not create it". + const { protocol, rows } = makeProtocol(environmentId, [ + { type, name: probe.name }, + ]); + const err = await protocol + .saveMetaItem({ type, name: probe.name, item: probe.item }) + .then(() => null, (e: any) => e); + + expect(err?.code).toBe('NOT_OVERRIDABLE'); + expect(err.status).toBe(403); + expect(err.message).toContain(`${type}/${probe.name}`); + expect(metaRows(rows)).toEqual([]); + }); + } + }); + } + + // ── no over-blocking: types WITHOUT the flags still save ────────────── + + describe('types the registry does not declare code-only still save', () => { + it('view (allowOrgOverride + allowRuntimeCreate) saves on a single kernel', async () => { + const { protocol, rows } = makeProtocol(undefined); + const result = await protocol.saveMetaItem({ + type: 'view', + name: 'rc3_probe_view', + item: { + name: 'rc3_probe_view', + label: 'Probe', + object: 'task', + columns: [{ field: 'name', label: 'Name' }], + }, + }); + expect(result.success).toBe(true); + expect(metaRows(rows).length).toBe(1); + }); + + it('hook (allowOrgOverride:false, allowRuntimeCreate:true) still saves on both kernels', async () => { + // The two-tier model (ADR-0005 PR-10d.7): no artifact at this name + // means only `allowRuntimeCreate` is required. This is the case the + // #5086 gate must NOT catch — it is the difference between "code-only" + // and "packaged items are locked". + for (const { environmentId } of KERNELS) { + const { protocol, rows } = makeProtocol(environmentId); + const result = await protocol.saveMetaItem({ + type: 'hook', + name: 'rc3_probe_hook', + item: { name: 'rc3_probe_hook', object: 'task', events: ['beforeUpdate'] }, + ...(environmentId ? { organizationId: 'org_alpha' } : {}), + }); + expect(result.success).toBe(true); + expect(metaRows(rows).length).toBe(1); + } + }); + + it('a plugin-registered type with no static registry entry still saves', async () => { + // `getMetaTypes()` synthesises those with allowRuntimeCreate:true; + // the write gate must keep agreeing with what it advertises. + const { protocol, rows } = makeProtocol(undefined); + const result = await protocol.saveMetaItem({ + type: 'theme', + name: 'rc3_probe_theme', + item: { name: 'rc3_probe_theme', label: 'Probe', tokens: {} }, + }); + expect(result.success).toBe(true); + expect(metaRows(rows).length).toBe(1); + }); + }); + + // ── one door, not two: the operator escape hatch still opens ────────── + + describe('OS_METADATA_WRITABLE stays the single escape hatch', () => { + for (const type of CODE_ONLY_TYPES) { + it(`unlocks ${type} on a single kernel when the operator sets it`, async () => { + const probe = PROBES[type]!; + process.env.OS_METADATA_WRITABLE = type; + ObjectStackProtocolImplementation.resetEnvWritableCache(); + resetEnvWritableMetadataTypes(); + + const { protocol } = makeProtocol(undefined); + // Only the gate is under test — a later stage may still object + // to the body, but never with the code-only verdict. + const err = await protocol + .saveMetaItem({ type, name: probe.name, item: probe.item }) + .then(() => null, (e: any) => e); + + expect(err?.code).not.toBe('NOT_CREATABLE'); + expect(err?.code).not.toBe('NOT_OVERRIDABLE'); + }); + } + }); +}); diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 65535778d2..c1c28940ec 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -6275,6 +6275,62 @@ export class ObjectStackProtocolImplementation implements return false; } + /** + * The prescription half of a code-only refusal (#5086): where the author + * is supposed to declare this item instead. Read from the type's own + * registry entry (`filePatterns`), so a newly-flagged type carries an + * accurate hint the day it is flagged — nothing here to keep in sync. + */ + private static codeOnlySourceHint(type: string): string { + const singular = PLURAL_TO_SINGULAR[type] ?? type; + const entry = DEFAULT_METADATA_TYPE_REGISTRY.find((e) => e.type === singular); + const pattern = entry?.filePatterns?.[0]; + return pattern ? ` Declare it in source (${pattern}) and redeploy.` : ''; + } + + /** + * #5086 — a brand-new item of a type the registry declares code-only + * (`allowRuntimeCreate: false` AND `allowOrgOverride: false`). + * + * Names the type, the flags that produced the verdict, the prescription + * and the escape hatch — the same shape the retired-key refusals in this + * wave carry, because a refusal an author cannot act on just moves the + * confusion one layer down. `NOT_CREATABLE` is the catalogued code + * (`packages/spec/src/api/error-code-ledger.zod.ts`). + */ + private static codeOnlyCreateError(type: string): Error { + const err = new Error( + `[not_creatable] Metadata type '${type}' is code-only: the metadata-type registry declares ` + + `allowRuntimeCreate=false and allowOrgOverride=false, so it cannot be created through the ` + + `runtime metadata API (PUT /api/v1/meta/${type}/:name) on any kernel.` + + ObjectStackProtocolImplementation.codeOnlySourceHint(type) + + ` An operator may set OS_METADATA_WRITABLE=${PLURAL_TO_SINGULAR[type] ?? type} to grant a runtime escape hatch. ` + + `See docs/adr/0005-metadata-customization-overlay.md.` + ); + (err as any).code = 'NOT_CREATABLE'; + (err as any).status = 403; + return err; + } + + /** + * #5086 — the artifact-backed half of the same refusal: the name IS + * shipped by a code package, so the honest verdict is "you may not + * overlay it" rather than "you may not create it". + */ + private static codeOnlyOverrideError(type: string, name: string): Error { + const err = new Error( + `[not_overridable] Metadata item '${type}/${name}' is provided by a code package and its type is ` + + `code-only (allowRuntimeCreate=false, allowOrgOverride=false), so it cannot be overlaid through ` + + `the runtime metadata API on any kernel.` + + ObjectStackProtocolImplementation.codeOnlySourceHint(type) + + ` An operator may set OS_METADATA_WRITABLE=${PLURAL_TO_SINGULAR[type] ?? type} to grant a runtime escape hatch. ` + + `See docs/adr/0005-metadata-customization-overlay.md.` + ); + (err as any).code = 'NOT_OVERRIDABLE'; + (err as any).status = 403; + return err; + } + /** * Does an artifact (npm-package-loaded) item exist at `(type, name)`? * @@ -6796,9 +6852,46 @@ export class ObjectStackProtocolImplementation implements // validations / triggers without unlocking the artifact-shadowing // capability. Returns `not_creatable` (vs `not_overridable`) so // the UI can present a tailored message. + const overlayAllowed = ObjectStackProtocolImplementation.isOverlayAllowed(request.type); + const runtimeCreateAllowed = ObjectStackProtocolImplementation.isRuntimeCreateAllowed(request.type); + + // #5086 — CODE-ONLY TYPES ARE REFUSED ON EVERY KERNEL, not only on + // project-scoped ones. A type whose registry entry sets BOTH + // `allowRuntimeCreate: false` AND `allowOrgOverride: false` declares + // that it has **no runtime write channel at all** — today `job` + // (#4509: `handler` resolves only through the compiled bundle's + // function table, so a runtime-created job could never be scheduled) + // and `agent` (ADR-0063 §2: platform-owned, per-org forks withdrawn). + // + // The rest of this block stays behind `environmentId !== undefined` + // because ADR-0005 §"Whitelist enforcement" deliberately keeps the + // *overlay* whitelist off single-kernel deployments ("keep their + // existing behaviour"). That sentence predates `allowRuntimeCreate` + // and speaks only of the overlay list — it never granted a topology + // the right to author a type the registry declares code-only. And the + // premise the carve-out rests on ("this kernel is the package + // author's own bootstrap channel") is simply not true for the CLI's + // lightweight assembler: a host config with instantiated plugins + // (`isHostConfig` → `shouldBootWithLibrary === false`) boots + // `new ObjectQLPlugin()` with NO environmentId, so the flagship + // showcase — a self-hosted app server whose `PUT /api/v1/meta/*` is + // an END-USER surface — ran with this entire gate disengaged. Keying + // authorization off a row-scoping key is what made a type-level + // declaration depend on deployment topology; the declaration decides + // it here instead. + // + // `isOverlayAllowed` still consults `OS_METADATA_WRITABLE`, so the + // documented operator escape hatch stays the ONE door: unlocking a + // type there unlocks it here too. `deleteMetaItem` is deliberately + // NOT gated the same way — removing a code-only row that predates + // this refusal is repair, and must stay possible. + if (!overlayAllowed && !runtimeCreateAllowed) { + throw this.isArtifactBacked(request.type, request.name) + ? ObjectStackProtocolImplementation.codeOnlyOverrideError(request.type, request.name) + : ObjectStackProtocolImplementation.codeOnlyCreateError(request.type); + } + if (this.environmentId !== undefined) { - const overlayAllowed = ObjectStackProtocolImplementation.isOverlayAllowed(request.type); - const runtimeCreateAllowed = ObjectStackProtocolImplementation.isRuntimeCreateAllowed(request.type); const artifactBacked = this.isArtifactBacked(request.type, request.name); if (artifactBacked && !overlayAllowed) { const err = new Error( @@ -6811,15 +6904,6 @@ export class ObjectStackProtocolImplementation implements (err as any).status = 403; throw err; } - if (!artifactBacked && !overlayAllowed && !runtimeCreateAllowed) { - const err = new Error( - `[not_creatable] Metadata type '${request.type}' does not allow runtime creation ` - + `(allowRuntimeCreate=false, allowOrgOverride=false). New items of this type must be defined in source code.` - ); - (err as any).code = 'NOT_CREATABLE'; - (err as any).status = 403; - throw err; - } // ADR-0010 L3 — per-item lock. Artifact `_lock` (or persisted // overlay `_lock`) blocks save independent of the L1 type-level diff --git a/packages/objectql/src/overlay-precedence.test.ts b/packages/objectql/src/overlay-precedence.test.ts index 4d4b33b5ab..180e9ac4eb 100644 --- a/packages/objectql/src/overlay-precedence.test.ts +++ b/packages/objectql/src/overlay-precedence.test.ts @@ -265,22 +265,38 @@ describe('overlay whitelist enforcement (shared-DB invariant)', () => { } }); - // ── single-kernel deployments: gate disengaged ── - describe('single-kernel mode (no environmentId) — gate bypassed', () => { - it('allows agent overlay when environmentId is undefined (gate bypassed)', async () => { + // ── single-kernel deployments: overlay gate disengaged ── + describe('single-kernel mode (no environmentId) — overlay gate bypassed', () => { + it('allows a hook overlay when environmentId is undefined (gate bypassed)', async () => { // No environmentId => not project-kernel mode => legacy "anything goes" // path used by control-plane bootstrap. ADR-0005 §"Whitelist". - // `agent` is a definitively-denied type in project-kernel mode - // (allowRuntimeCreate: false, ADR-0063), so this case best - // demonstrates the bypass semantics. + // + // The specimen used to be `agent`, which #5086 moved out from under + // this bypass: a type declaring BOTH `allowRuntimeCreate: false` and + // `allowOrgOverride: false` is code-only and is refused on EVERY + // kernel (see `protocol.code-only-types.test.ts`). What ADR-0005's + // sentence actually granted single kernels — the *overlay* whitelist + // staying off — is unchanged, so `hook` (allowOrgOverride:false, + // allowRuntimeCreate:true) is now the honest specimen for it. const { protocol: localProto } = makeProtocol({ environmentId: undefined }); const result = await localProto.saveMetaItem({ - type: 'agent', - name: 'my_agent', - item: { name: 'my_agent', label: 'My Agent', role: 'assistant', instructions: 'Answer questions about test data.' }, + type: 'hook', + name: 'my_hook', + item: { name: 'my_hook', object: 'case', events: ['beforeUpdate'] }, }); expect(result.success).toBe(true); }); + + it('refuses a code-only type even with environmentId undefined (#5086)', async () => { + const { protocol: localProto } = makeProtocol({ environmentId: undefined }); + await expect( + localProto.saveMetaItem({ + type: 'agent', + name: 'my_agent', + item: { name: 'my_agent', label: 'My Agent', role: 'assistant', instructions: 'Answer questions about test data.' }, + }), + ).rejects.toMatchObject({ code: 'NOT_CREATABLE', status: 403 }); + }); }); // ── registry invariant: whitelist derives from spec, no parallel list ──