From 8076e9a7e62f2daef30a0b88f7f2c9cd9155a0c0 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 13:15:20 +0000 Subject: [PATCH] =?UTF-8?q?fix(metadata-protocol):=20enforce=20allowRuntim?= =?UTF-8?q?eCreate=20on=20every=20kernel=20=E2=80=94=20PUT=20/meta=20no=20?= =?UTF-8?q?longer=20creates=20code-only=20job/agent=20items=20(#5086)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ADR-0005 two-tier authorization gate sat behind `environmentId !== undefined`. `environmentId` is a row-scoping key, not an authorization signal: a host config with instantiated plugins skips the standalone stack and boots `new ObjectQLPlugin()` with no environmentId, so the showcase — and every self-hosted app server shaped like it — ran with the gate disengaged. A `job` whose `handler` names no compiled function persisted and answered 200. A type declaring BOTH `allowRuntimeCreate: false` and `allowOrgOverride: false` has no runtime write channel at all, in any topology. That verdict is now taken from the registry declaration alone, before persistence, for draft and publish saves alike, and returns the catalogued NOT_CREATABLE / NOT_OVERRIDABLE 403 naming the type, the flags, the source file pattern to declare it in and the OS_METADATA_WRITABLE escape hatch. The overlay whitelist keeps its single-kernel carve-out (ADR-0005 §"Whitelist enforcement" predates `allowRuntimeCreate` and speaks only of that list), and `deleteMetaItem` stays permissive so a code-only row that predates this refusal can still be removed. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Pbu27iNUfQCHeuS551Rqo7 --- ...-only-metadata-types-refused-everywhere.md | 68 ++++ .../src/protocol.code-only-types.test.ts | 324 ++++++++++++++++++ packages/metadata-protocol/src/protocol.ts | 106 +++++- .../objectql/src/overlay-precedence.test.ts | 34 +- 4 files changed, 512 insertions(+), 20 deletions(-) create mode 100644 .changeset/code-only-metadata-types-refused-everywhere.md create mode 100644 packages/metadata-protocol/src/protocol.code-only-types.test.ts 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 ──