Skip to content

Commit 1f82d1e

Browse files
os-zhuangclaude
andauthored
fix(metadata-protocol): enforce allowRuntimeCreate on every kernel — PUT /meta no longer creates code-only job/agent items (#5086) (#5263)
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. Claude-Session: https://claude.ai/code/session_01Pbu27iNUfQCHeuS551Rqo7 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 95b4f0d commit 1f82d1e

4 files changed

Lines changed: 512 additions & 20 deletions

File tree

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
---
2+
"@objectstack/metadata-protocol": patch
3+
---
4+
5+
fix(metadata-protocol): `allowRuntimeCreate: false` is enforced on every kernel — `PUT /meta` no longer creates `job` / `agent` items the registry declares code-only (#5086)
6+
7+
#4509 set `allowRuntimeCreate: false` on `job` and promised the refusal without
8+
qualification — *no "create job" in Studio or via `PUT /meta`*. ADR-0063 §2 says
9+
the same for `agent`. The gate that keeps that promise existed, and worked, but
10+
it sat behind `environmentId !== undefined`:
11+
12+
```ts
13+
if (this.environmentId !== undefined) {
14+
// …not_overridable / not_creatable…
15+
}
16+
```
17+
18+
`environmentId` is a **row-scoping key**, not an authorization signal. Every
19+
kernel assembled without one ran with the entire ADR-0005 authorization gate
20+
disengaged — and that is not an exotic topology. The CLI's lightweight
21+
assembler builds exactly that for a host config (`isHostConfig` → the
22+
`createStandaloneStack` branch is skipped → `new ObjectQLPlugin()` with no
23+
`environmentId`), which is the flagship showcase and every self-hosted app
24+
server shaped like it. On those, the issue's repro answered:
25+
26+
```
27+
PUT /api/v1/meta/job/rc3_runtime_job
28+
{"name":"rc3_runtime_job","label":"J",
29+
"schedule":{"type":"cron","expression":"0 0 * * *"},"handler":"nope"}
30+
→ 200 {"success":true,"message":"Saved customization overlay (env-wide) — type=job, …"}
31+
```
32+
33+
`handler: "nope"` names no function in any compiled bundle. The row persists,
34+
lists, and can never be scheduled — the record #4509 exists to prevent, saved
35+
and reported as success. It is the ADR-0049 failure mode one level up: the
36+
*enforcement flag itself* was the silently-inert declaration, and Studio (which
37+
reads the flag to hide "create") honoured a rule the API underneath did not.
38+
39+
**What changed.** A type whose registry entry sets BOTH `allowRuntimeCreate:
40+
false` AND `allowOrgOverride: false` declares that it has no runtime write
41+
channel at all. `saveMetaItem` now refuses it on every kernel, before
42+
persistence, in draft mode as well as publish:
43+
44+
| write | before | now |
45+
|---|---|---|
46+
| `PUT /meta/job/*` on a single-kernel host | `200 success` | `403 NOT_CREATABLE` |
47+
| `PUT /meta/agent/*` on a single-kernel host | `200 success` | `403 NOT_CREATABLE` |
48+
| same, over a name a code package ships | `200 success` | `403 NOT_OVERRIDABLE` |
49+
| project-scoped (cloud) kernels | `403` | `403` (unchanged) |
50+
51+
The refusal names the type, the flags that produced the verdict, the source
52+
file pattern to declare it in (read from the type's own registry entry, so a
53+
newly-flagged type carries an accurate hint the day it is flagged) and the
54+
`OS_METADATA_WRITABLE` escape hatch.
55+
56+
**Scope, deliberately.** The rest of the ADR-0005 two-tier gate keeps its
57+
single-kernel carve-out: that ADR's "single-kernel deployments keep their
58+
existing behaviour" sentence is about the *overlay whitelist*, predates
59+
`allowRuntimeCreate` entirely, and a type that stays runtime-creatable
60+
(`object`, `hook`, `field`, `seed`, `mapping`, …) is untouched here. So is
61+
`deleteMetaItem` — removing a code-only row that predates this refusal is
62+
repair and must stay possible. `OS_METADATA_WRITABLE` remains the one door:
63+
unlocking a type there unlocks it here too.
64+
65+
**Upgrading.** If a deployment relies on runtime-created `job` or `agent` rows,
66+
move them into source (`**/*.job.ts`, `**/*.agent.ts`) and redeploy — a `job`
67+
authored at runtime never had a reachable `handler` in the first place. To keep
68+
writing them while migrating, set `OS_METADATA_WRITABLE=job,agent`.
Lines changed: 324 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,324 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #5086 — `allowRuntimeCreate: false` is enforced on EVERY kernel.
5+
*
6+
* #4509 set `allowRuntimeCreate: false` on `job` and its changeset promised
7+
* the refusal without qualification — *no "create job" in Studio or via
8+
* `PUT /meta`*. ADR-0063 §2 says the same for `agent`. The gate that keeps
9+
* that promise sat behind `environmentId !== undefined`, so it ran on
10+
* project-scoped (cloud per-env) kernels only. Every kernel assembled
11+
* WITHOUT an environmentId — which is what the CLI's lightweight assembler
12+
* builds for a host config (`isHostConfig` → `shouldBootWithLibrary` false →
13+
* `new ObjectQLPlugin()`), i.e. the flagship showcase and every self-hosted
14+
* app server shaped like it — accepted the write and answered
15+
* `200 {"success":true,"message":"Saved customization overlay (env-wide) …"}`
16+
* for a `job` whose `handler` names no function in any compiled bundle.
17+
*
18+
* So these tests run every case against BOTH kernel shapes. The unscoped one
19+
* is the regression; the scoped one pins the behaviour that already worked so
20+
* the two topologies can never drift apart again.
21+
*
22+
* The flags are DATA (`DEFAULT_METADATA_TYPE_REGISTRY`), so the suite is
23+
* data-driven: it derives the code-only set from the registry and fails when
24+
* a newly-flagged type arrives without a probe payload here. Covering the
25+
* next flagged type costs one entry in {@link PROBES}.
26+
*
27+
* Harness: the real write path over a stub engine (same shape as
28+
* `protocol.runtime-authoring-gate.test.ts`) — a gate INSIDE `saveMetaItem`
29+
* cannot be tested against a harness that mocks `saveMetaItem`.
30+
*/
31+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
32+
import { DEFAULT_METADATA_TYPE_REGISTRY } from '@objectstack/spec/kernel';
33+
import { ObjectStackProtocolImplementation } from './protocol.js';
34+
import { resetEnvWritableMetadataTypes } from './sys-metadata-repository.js';
35+
36+
interface Row {
37+
id: string;
38+
type: string;
39+
name: string;
40+
organization_id: string | null;
41+
state: string;
42+
metadata: string;
43+
}
44+
45+
/**
46+
* A type is code-only when the registry gives it NO runtime write channel at
47+
* all. Derived, never hardcoded — Prime Directive #8 ("no parallel
48+
* whitelists"): if someone flags a third type, this set grows by itself and
49+
* the coverage guard below turns red until it has a probe.
50+
*/
51+
const CODE_ONLY_TYPES = DEFAULT_METADATA_TYPE_REGISTRY
52+
.filter((e) => !e.allowRuntimeCreate && !e.allowOrgOverride)
53+
.map((e) => e.type);
54+
55+
/**
56+
* Schema-VALID bodies, straight from the issue's repro. This matters: a
57+
* minimal payload 422s on spec validation first, which is exactly what made
58+
* the missing gate easy to miss — only a body the schema accepts proves the
59+
* refusal came from the registry consult.
60+
*/
61+
const PROBES: Record<string, { name: string; item: Record<string, unknown> }> = {
62+
job: {
63+
name: 'rc3_runtime_job',
64+
item: {
65+
name: 'rc3_runtime_job',
66+
label: 'J',
67+
schedule: { type: 'cron', expression: '0 0 * * *' },
68+
handler: 'nope',
69+
},
70+
},
71+
agent: {
72+
name: 'rc3_agent_probe',
73+
item: {
74+
name: 'rc3_agent_probe',
75+
label: 'A',
76+
role: 'assistant',
77+
instructions: 'be helpful',
78+
},
79+
},
80+
};
81+
82+
function makeStubEngine(artifacts: Array<{ type: string; name: string }> = []) {
83+
const rows = new Map<string, Row>();
84+
let nextId = 0;
85+
const artifactKeys = new Set(artifacts.map((a) => `${a.type}|${a.name}`));
86+
const keyOf = (w: Record<string, unknown>) =>
87+
`${w.type}|${w.name}|${w.organization_id ?? '__env__'}|${w.state ?? 'active'}`;
88+
const engine: any = {
89+
async findOne(_t: string, opts: { where: Record<string, unknown> }) {
90+
for (const row of rows.values()) {
91+
if (opts.where.type !== undefined && row.type !== opts.where.type) continue;
92+
if (opts.where.name !== undefined && row.name !== opts.where.name) continue;
93+
return row;
94+
}
95+
return null;
96+
},
97+
async find() { return []; },
98+
async insert(_t: string, data: Record<string, unknown>) {
99+
if (_t !== 'sys_metadata') return { id: 'side_effect_skip' };
100+
nextId += 1;
101+
const row = { id: `r_${nextId}`, ...(data as any) } as Row;
102+
rows.set(keyOf(data), row);
103+
return { id: row.id };
104+
},
105+
async update() { return { id: null }; },
106+
async delete() { return { deleted: 0 }; },
107+
registry: {
108+
registerItem: () => {},
109+
registerObject: () => {},
110+
listItems: () => [],
111+
getItem: () => undefined,
112+
// `isArtifactBacked` prefers this lookup — a hit here means the
113+
// name is shipped by a code package (`_packageId` provenance).
114+
getArtifactItem: (type: string, name: string) =>
115+
artifactKeys.has(`${type}|${name}`) ? { name, _packageId: 'showcase' } : undefined,
116+
},
117+
};
118+
return { engine, rows };
119+
}
120+
121+
/**
122+
* The two kernel shapes this issue is about.
123+
*
124+
* • `single-kernel` — `environmentId` undefined. The CLI lightweight
125+
* assembler / showcase topology, where the gate never ran (the bug).
126+
* • `project-kernel` — `environmentId` set. Cloud per-env kernels, where
127+
* the gate already ran (the pin).
128+
*/
129+
const KERNELS: Array<{ label: string; environmentId?: string }> = [
130+
{ label: 'single-kernel (no environmentId)' },
131+
{ label: 'project-kernel (environmentId set)', environmentId: 'env_test' },
132+
];
133+
134+
function makeProtocol(environmentId?: string, artifacts?: Array<{ type: string; name: string }>) {
135+
const { engine, rows } = makeStubEngine(artifacts);
136+
const protocol = new ObjectStackProtocolImplementation(
137+
engine,
138+
() => new Map(),
139+
environmentId,
140+
) as any;
141+
return { protocol, rows };
142+
}
143+
144+
const metaRows = (rows: Map<string, Row>) => Array.from(rows.values());
145+
146+
describe('code-only metadata types are refused on every kernel (#5086)', () => {
147+
beforeEach(() => {
148+
delete process.env.OS_METADATA_WRITABLE;
149+
// Two memoised readers of the same env var — the protocol's gate and
150+
// the repository's `assertAllowed`. Both must be reset or the second
151+
// one answers from a stale parse.
152+
ObjectStackProtocolImplementation.resetEnvWritableCache();
153+
resetEnvWritableMetadataTypes();
154+
});
155+
afterEach(() => {
156+
delete process.env.OS_METADATA_WRITABLE;
157+
// Two memoised readers of the same env var — the protocol's gate and
158+
// the repository's `assertAllowed`. Both must be reset or the second
159+
// one answers from a stale parse.
160+
ObjectStackProtocolImplementation.resetEnvWritableCache();
161+
resetEnvWritableMetadataTypes();
162+
});
163+
164+
// ── the flags are data: keep the suite honest about new ones ──────────
165+
166+
it('covers every code-only type the registry declares', () => {
167+
// Today: job (#4509) and agent (ADR-0063 §2). When a third type is
168+
// flagged, this fails until it has a schema-valid probe above —
169+
// which is the whole cost of covering it.
170+
expect(CODE_ONLY_TYPES.length).toBeGreaterThan(0);
171+
expect([...CODE_ONLY_TYPES].sort()).toEqual(['agent', 'job']);
172+
for (const type of CODE_ONLY_TYPES) {
173+
expect(PROBES[type], `no probe payload for code-only type '${type}'`).toBeDefined();
174+
}
175+
});
176+
177+
// ── the refusal, per flagged type, on both kernels ────────────────────
178+
179+
for (const { label, environmentId } of KERNELS) {
180+
describe(label, () => {
181+
for (const type of CODE_ONLY_TYPES) {
182+
const probe = PROBES[type]!;
183+
184+
it(`refuses a schema-valid ${type} create with the catalogued code`, async () => {
185+
const { protocol, rows } = makeProtocol(environmentId);
186+
187+
const err = await protocol
188+
.saveMetaItem({ type, name: probe.name, item: probe.item })
189+
.then(() => null, (e: any) => e);
190+
191+
expect(err, 'the write was accepted').not.toBeNull();
192+
expect(err.code).toBe('NOT_CREATABLE');
193+
expect(err.status).toBe(403);
194+
// Names the type and why it is code-only — a refusal the
195+
// author can act on without reading the source.
196+
expect(err.message).toContain(`'${type}'`);
197+
expect(err.message).toContain('allowRuntimeCreate=false');
198+
expect(err.message).toContain('code-only');
199+
200+
// A gate that refuses AFTER persisting is a log line.
201+
expect(metaRows(rows)).toEqual([]);
202+
});
203+
204+
it(`refuses an org-scoped ${type} create too`, async () => {
205+
const { protocol, rows } = makeProtocol(environmentId);
206+
const err = await protocol
207+
.saveMetaItem({
208+
type,
209+
name: probe.name,
210+
item: probe.item,
211+
organizationId: 'org_alpha',
212+
})
213+
.then(() => null, (e: any) => e);
214+
215+
expect(err?.code).toBe('NOT_CREATABLE');
216+
expect(metaRows(rows)).toEqual([]);
217+
});
218+
219+
it(`refuses a ${type} DRAFT save (Studio's staging door)`, async () => {
220+
// #4509 is explicit that Studio must not offer "create" at
221+
// all — staging it as a draft first is the same create.
222+
const { protocol, rows } = makeProtocol(environmentId);
223+
const err = await protocol
224+
.saveMetaItem({ type, name: probe.name, item: probe.item, mode: 'draft' })
225+
.then(() => null, (e: any) => e);
226+
227+
expect(err?.code).toBe('NOT_CREATABLE');
228+
expect(metaRows(rows)).toEqual([]);
229+
});
230+
231+
it(`refuses overlaying an artifact-backed ${type} with not_overridable`, async () => {
232+
// Same verdict, honest reason: the name IS shipped by a
233+
// code package, so "you may not overlay it" beats "you may
234+
// not create it".
235+
const { protocol, rows } = makeProtocol(environmentId, [
236+
{ type, name: probe.name },
237+
]);
238+
const err = await protocol
239+
.saveMetaItem({ type, name: probe.name, item: probe.item })
240+
.then(() => null, (e: any) => e);
241+
242+
expect(err?.code).toBe('NOT_OVERRIDABLE');
243+
expect(err.status).toBe(403);
244+
expect(err.message).toContain(`${type}/${probe.name}`);
245+
expect(metaRows(rows)).toEqual([]);
246+
});
247+
}
248+
});
249+
}
250+
251+
// ── no over-blocking: types WITHOUT the flags still save ──────────────
252+
253+
describe('types the registry does not declare code-only still save', () => {
254+
it('view (allowOrgOverride + allowRuntimeCreate) saves on a single kernel', async () => {
255+
const { protocol, rows } = makeProtocol(undefined);
256+
const result = await protocol.saveMetaItem({
257+
type: 'view',
258+
name: 'rc3_probe_view',
259+
item: {
260+
name: 'rc3_probe_view',
261+
label: 'Probe',
262+
object: 'task',
263+
columns: [{ field: 'name', label: 'Name' }],
264+
},
265+
});
266+
expect(result.success).toBe(true);
267+
expect(metaRows(rows).length).toBe(1);
268+
});
269+
270+
it('hook (allowOrgOverride:false, allowRuntimeCreate:true) still saves on both kernels', async () => {
271+
// The two-tier model (ADR-0005 PR-10d.7): no artifact at this name
272+
// means only `allowRuntimeCreate` is required. This is the case the
273+
// #5086 gate must NOT catch — it is the difference between "code-only"
274+
// and "packaged items are locked".
275+
for (const { environmentId } of KERNELS) {
276+
const { protocol, rows } = makeProtocol(environmentId);
277+
const result = await protocol.saveMetaItem({
278+
type: 'hook',
279+
name: 'rc3_probe_hook',
280+
item: { name: 'rc3_probe_hook', object: 'task', events: ['beforeUpdate'] },
281+
...(environmentId ? { organizationId: 'org_alpha' } : {}),
282+
});
283+
expect(result.success).toBe(true);
284+
expect(metaRows(rows).length).toBe(1);
285+
}
286+
});
287+
288+
it('a plugin-registered type with no static registry entry still saves', async () => {
289+
// `getMetaTypes()` synthesises those with allowRuntimeCreate:true;
290+
// the write gate must keep agreeing with what it advertises.
291+
const { protocol, rows } = makeProtocol(undefined);
292+
const result = await protocol.saveMetaItem({
293+
type: 'theme',
294+
name: 'rc3_probe_theme',
295+
item: { name: 'rc3_probe_theme', label: 'Probe', tokens: {} },
296+
});
297+
expect(result.success).toBe(true);
298+
expect(metaRows(rows).length).toBe(1);
299+
});
300+
});
301+
302+
// ── one door, not two: the operator escape hatch still opens ──────────
303+
304+
describe('OS_METADATA_WRITABLE stays the single escape hatch', () => {
305+
for (const type of CODE_ONLY_TYPES) {
306+
it(`unlocks ${type} on a single kernel when the operator sets it`, async () => {
307+
const probe = PROBES[type]!;
308+
process.env.OS_METADATA_WRITABLE = type;
309+
ObjectStackProtocolImplementation.resetEnvWritableCache();
310+
resetEnvWritableMetadataTypes();
311+
312+
const { protocol } = makeProtocol(undefined);
313+
// Only the gate is under test — a later stage may still object
314+
// to the body, but never with the code-only verdict.
315+
const err = await protocol
316+
.saveMetaItem({ type, name: probe.name, item: probe.item })
317+
.then(() => null, (e: any) => e);
318+
319+
expect(err?.code).not.toBe('NOT_CREATABLE');
320+
expect(err?.code).not.toBe('NOT_OVERRIDABLE');
321+
});
322+
}
323+
});
324+
});

0 commit comments

Comments
 (0)