From 43ede0010ee15102739cd777259bb51ca5d6245d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 08:47:17 +0000 Subject: [PATCH 1/5] fix(metadata-protocol): the ADR-0010 _lock gate answers on every topology, and the diagnostics locked count reads the envelope's derivation Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude --- packages/metadata-protocol/src/protocol.ts | 85 ++++++++++++++++++---- 1 file changed, 72 insertions(+), 13 deletions(-) diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 6fca322df1b..185d1380a0d 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -7672,7 +7672,9 @@ export class ObjectStackProtocolImplementation implements * Per-type aggregate stats — count of items and the list of * packages contributing to each type. Computed in the same * sweep so the Studio directory page can render tile counts - * and a package filter in one round-trip. + * and a package filter in one round-trip. `locked` is the number + * of items whose read envelope reports `lock` other than `'none'` + * — the same derivation `getMetaItem` publishes (#21694). */ stats: Record; }> { @@ -7804,8 +7806,14 @@ export class ObjectStackProtocolImplementation implements scannedItems += 1; const pkg = (item?._packageId ?? null) as string | null; if (pkg) pkgSet.add(pkg); - const lock = item?._lock as string | undefined; - if (lock && lock !== 'none') lockedCount += 1; + // [#21694] Counted from the envelope's own derivation + // ({@link servedLockState}), never from the declared `_lock` + // alone: a packaged base the write doors refuse reads locked on + // its item envelope, so the per-type tile counts it too. The + // derivation reads the registry only — no store read per item. + const itemName = typeof item?.name === 'string' ? item.name : ''; + const served = this.servedLockState(t, itemName, item, this.isArtifactBacked(t, itemName)); + if (served.lock !== 'none') lockedCount += 1; const diag: MetadataDiagnostics | undefined = item?._diagnostics ?? computeMetadataDiagnostics(t, item); if (!diag) continue; @@ -15593,14 +15601,17 @@ export class ObjectStackProtocolImplementation implements * publishes beside the document — `lock`, `editable`, `deletable` and the * rest — for `(type, name)`, whose served document is `document`. The ONE * derivation both reads call: {@link getMetaItem} and - * {@link getMetaItemLayered}. + * {@link getMetaItemLayered} — and [#21694] the per-type `locked` count of + * {@link getMetaDiagnostics}, so the directory tile and the item agree. * * The flags are a promise about the write doors: `editable` says whether a * write of this item is refused on lock grounds, `deletable` whether its * removal is. Two limbs refuse such a write, so both are asked here, and * neither is re-derived: * - * - the item's own ADR-0010 `_lock` — `resolveLockState`, unchanged; + * - the item's own ADR-0010 `_lock` — `resolveLockState`, unchanged. Its + * door ({@link lockWriteRefusal} / {@link assertLockAllowsDelete}) + * answers on every topology since #21694, as the package limb does; * - the locked packaged base: an item a code package ships, on a type with * no overlay channel — {@link packagedBaseRefusal}, the verdict the * `/meta` doors and the `/automation` doors already share (`NOT_OVERRIDABLE`, @@ -16381,6 +16392,26 @@ export class ObjectStackProtocolImplementation implements * a transaction keep using `assertLockAllowsWrite` unchanged — this is an * extraction, not a behaviour change, and `save` / `rollback` still get * their row from the same expression they always did. + * + * ## [#21694] Topology-INDEPENDENT, like the package door + * + * This gate, and {@link assertLockAllowsDelete} beside it, used to open + * with `if (this.environmentId === undefined) return null;`. No ADR + * records that carve-out: ADR-0010 §3.3 states the lock table with no + * topology column, and the only reason ever written down was the title of + * the test that pinned it ("control-plane bootstrap"). That is the + * inference #6710 refuted (see {@link MetadataAuthoringChannel}): + * `environmentId` is a row-scoping key, and the CLI's host-config + * assembler — the showcase's own boot shape — leaves it undefined while it + * serves an end-user `PUT /api/v1/meta/*`. So a host-config kernel + * admitted every save, publish, rollback and delete of a `_lock`ed item, + * while both reads ({@link servedLockState}) reported it locked: the door + * looser than the read, on the topology the flagship app boots. + * + * The gate now answers alike on every kernel, as {@link + * packagedBaseRefusal} does. Where it sits relative to that package door + * is each caller's ordering, and it is the same on every topology: see the + * `saveMetaItem` and `deleteMetaItem` call sites. */ private async lockWriteRefusal(args: { type: string; @@ -16391,7 +16422,6 @@ export class ObjectStackProtocolImplementation implements source?: string; requestId?: string; }): Promise<{ err: Error; audit: MetadataAuditEntry } | null> { - if (this.environmentId === undefined) return null; const state = await this.getEffectiveLock(args.type, args.name, args.organizationId ?? null); const refusal = evaluateLockForWrite(state.lock); if (!refusal) return null; @@ -16450,7 +16480,10 @@ export class ObjectStackProtocolImplementation implements return refusal.err; } - /** Counterpart of {@link assertLockAllowsWrite} for delete. */ + /** + * Counterpart of {@link assertLockAllowsWrite} for delete. [#21694] + * Topology-independent for the same reason — see {@link lockWriteRefusal}. + */ private async assertLockAllowsDelete(args: { type: string; name: string; @@ -16459,7 +16492,6 @@ export class ObjectStackProtocolImplementation implements source?: string; requestId?: string; }): Promise { - if (this.environmentId === undefined) return null; const state = await this.getEffectiveLock(args.type, args.name, args.organizationId ?? null); const refusal = evaluateLockForDelete(state.lock); if (!refusal) return null; @@ -18515,11 +18547,29 @@ export class ObjectStackProtocolImplementation implements // {@link packagedBaseRefusal}, rather than a copy that agrees with // this one only until either of them moves. this.refusePackagedBaseOverride(request); + } - // ADR-0010 L3 — per-item lock. Artifact `_lock` (or persisted - // overlay `_lock`) blocks save independent of the L1 type-level - // flag. Records the denial in `sys_metadata_audit` before - // throwing so refused attempts are visible in compliance reports. + // ADR-0010 L3 — per-item lock. Artifact `_lock` (or persisted + // overlay `_lock`) blocks save independent of the L1 type-level + // flag. Records the denial in `sys_metadata_audit` before + // throwing so refused attempts are visible in compliance reports. + // + // [#21694] On EVERY topology — it used to sit inside the block above, + // so a host-config kernel never asked it (see {@link lockWriteRefusal}). + // Its rank is unchanged and is the same on every kernel: BELOW the + // package door. On an environment kernel that door has thrown above + // whenever it refuses, so the condition is always true there; on a + // host-config kernel the same door answers at the repository write + // (`SysMetadataRepository.assertAllowed`), so a packaged base it will + // refuse is left to it. One request, one refusal code, on both kernels + // — the `_lock` gate never pre-empts `NOT_OVERRIDABLE` on one topology + // only. + if (this.packagedBaseRefusal({ + type: request.type, + name: request.name, + operation: 'save', + ...(request.packageId ? { packageId: request.packageId } : {}), + }) === null) { const lockErr = await this.assertLockAllowsWrite({ type: request.type, name: request.name, @@ -24635,8 +24685,17 @@ export class ObjectStackProtocolImplementation implements (err as any).status = 403; throw err; } + } - // ADR-0010 L3 — lock blocks delete. + // ADR-0010 L3 — lock blocks delete. [#21694] On EVERY topology, ranked + // below the package removal door exactly as `saveMetaItem` ranks the + // save gate below its package door (see the note there): on an + // environment kernel that door has thrown above whenever it refuses; + // on a host-config kernel a packaged base it refuses keeps the answer + // that kernel already gave it — the repository's delete gate when an + // overlay row exists, a no-op that leaves the artifact standing when + // none does (see {@link packagedBaseRefusal}). + if (this.packagedBaseRefusal({ type: request.type, name: request.name, operation: 'delete' }) === null) { const lockErr = await this.assertLockAllowsDelete({ type: request.type, name: request.name, From 3041bb5fbedd104aa2a27733287b5d2662c118bf Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 08:54:25 +0000 Subject: [PATCH 2/5] test(metadata-protocol): pin the _lock door, the read envelope and the diagnostics count agreeing on both topologies Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude --- .../src/protocol.lock-door-read-agree.test.ts | 295 ++++++++++++++++++ .../src/protocol-lock-enforcement.test.ts | 23 +- 2 files changed, 311 insertions(+), 7 deletions(-) create mode 100644 packages/metadata-protocol/src/protocol.lock-door-read-agree.test.ts diff --git a/packages/metadata-protocol/src/protocol.lock-door-read-agree.test.ts b/packages/metadata-protocol/src/protocol.lock-door-read-agree.test.ts new file mode 100644 index 00000000000..19a97a7d71d --- /dev/null +++ b/packages/metadata-protocol/src/protocol.lock-door-read-agree.test.ts @@ -0,0 +1,295 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#21694, ADR-0010 §3.3 / §5] One fact — "is this item locked?" — reported + * three ways, and the three must agree on every topology: + * + * - the WRITE DOORS (`saveMetaItem` / `deleteMetaItem` / `publishMetaItem`), + * whose ADR-0010 `_lock` gate used to return no refusal on a kernel with no + * `environmentId` — the host-config shape the CLI assembles for a stack + * with instantiated plugins, which is the showcase's own boot; + * - the item READ's protection envelope (`lock` / `editable` / `deletable`), + * which reported the declared `_lock` on every kernel; + * - the per-type `locked` count of `getMetaDiagnostics`, which counted the + * declared `_lock` only, so a packaged base the doors refuse in place (and + * whose envelope reads locked since #21670) was missing from the tile. + * + * Pins, each against the real door and the real read: + * + * 1. on a host-config kernel, an overlay item with a declared `_lock` reads + * and saves consistently — refused where the read says not editable, + * admitted past the gate where it says editable — and deletes + * consistently too, including a packaged item the package door lets + * through to the `_lock` gate (an `app`, the platform's own locked shape); + * 2. the per-type locked count equals the number of items whose envelope + * reads locked, including an item locked only by the package door; + * 3. an environment-bound kernel is unchanged: the same refusal codes, the + * same envelope. + * + * The `_lock` gate ranks BELOW the package door on both kernels, so a + * packaged base keeps its `NOT_OVERRIDABLE` on a host-config kernel too — the + * gate must not pre-empt it there only (pinned with pin 3's twin). + * + * `@objectstack/objectql` cannot be imported here: it depends on this package. + */ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { assertEngineFindOnePredicate, isCodeArtifactBody } from '@objectstack/metadata-core'; +import { ObjectStackProtocolImplementation } from './protocol.js'; + +const PACKAGE_ID = 'com.example.pkg'; +const ENV_ID = 'env_1'; +const LOCKS = ['none', 'no-overlay', 'no-delete', 'full'] as const; +type Lock = typeof LOCKS[number]; + +interface StoredRow { + id: string; + type: string; + name: string; + organization_id: string | null; + package_id: string | null; + state: string; + metadata: string; +} + +type Artifacts = Record>>; + +/** A tenant-authored `sys_metadata` row — what an author's save of a body declaring `_lock` leaves at rest. */ +const overlayRow = (type: string, name: string, lock: Lock): StoredRow => ({ + id: `r_${type}_${name}`, + type, + name, + organization_id: null, + package_id: null, + state: 'active', + metadata: JSON.stringify({ + name, + label: name, + object: 'account', + _provenance: 'org', + ...(lock === 'none' ? {} : { _lock: lock }), + }), +}); + +/** What a code package's loader registered: package-stamped, with an optional `_lock`. */ +const packaged = (name: string, lock: Lock, extra: Record = {}): Record => ({ + name, + label: name, + _packageId: PACKAGE_ID, + _provenance: 'package', + ...(lock === 'none' ? {} : { _lock: lock }), + ...extra, +}); + +/** + * The engine double: `find` / `findOne` over `sys_metadata` rows, a registry + * whose artifact lookup answers what the loader registered, and an `insert` + * that keeps nothing (the `_lock` gate records its denial through it). No + * write verbs beyond that — an admitted write is read off the gate itself. + */ +function harness(environmentId: string | undefined, rows: StoredRow[] = [], artifacts: Artifacts = {}) { + const registry = { + getArtifactItem(type: string, name: string) { + const hit = artifacts[type]?.[name]; + return hit && isCodeArtifactBody(hit) ? hit : undefined; + }, + getItem(type: string, name: string) { + return artifacts[type]?.[name]; + }, + listItems(type: string) { + return Object.values(artifacts[type] ?? {}); + }, + getObject: () => undefined, + registerObject: () => undefined, + getPackage: () => undefined, + isPackageDisabled: () => false, + applyNavContributions: (app: unknown) => app, + }; + const matching = (where: Record) => { + for (const k of Object.keys(where)) { + if (k.startsWith('$')) throw new Error(`[test double] unsupported WHERE combinator '${k}'`); + } + return rows.filter((r) => + Object.entries(where).every(([k, v]) => v === undefined || (r as unknown as Record)[k] === v), + ); + }; + const engine: any = { + async find(table: string, opts?: { where?: Record; limit?: number }) { + if (table !== 'sys_metadata') return []; + const matched = matching(opts?.where ?? {}); + // `check:objectql-double-limit` — the caller's bound, applied after the filter. + return opts?.limit === undefined ? matched : matched.slice(0, opts.limit); + }, + async findOne(table: string, opts?: { where?: Record }) { + // `check:engine-double-contract` — refuses what the real engine refuses. + assertEngineFindOnePredicate(table, opts); + if (table !== 'sys_metadata') return null; + return matching(opts?.where ?? {})[0] ?? null; + }, + async insert() { + return {}; + }, + registry, + }; + return new ObjectStackProtocolImplementation(engine, () => new Map(), environmentId); +} + +const settle = (run: Promise) => run.then(() => null, (e: unknown) => e); + +type Verdict = { refused: { code: unknown; status: unknown } } | 'admitted'; + +/** + * The door, end to end. Refused ⇔ a 403 lock-family envelope came back. + * Admitted ⇔ the ADR-0010 `_lock` gate was reached and answered no refusal; + * whatever the write does after that (validation, a double that persists + * nothing) is not a lock verdict and is not read as one. + */ +async function door( + protocol: ObjectStackProtocolImplementation, type: string, name: string, operation: 'save' | 'delete', + item: Record = { name, label: name, object: 'account' }, +): Promise { + const gate = vi.spyOn(protocol as any, operation === 'save' ? 'assertLockAllowsWrite' : 'assertLockAllowsDelete'); + try { + const outcome: any = await settle(operation === 'save' + ? protocol.saveMetaItem({ type, name, item }) + : protocol.deleteMetaItem({ type, name })); + if (outcome instanceof Error && (outcome as any).status === 403 + && ((outcome as any).code === 'ITEM_LOCKED' || (outcome as any).code === 'NOT_OVERRIDABLE')) { + return { refused: { code: (outcome as any).code, status: (outcome as any).status } }; + } + expect(gate, `${type}/${name} ${operation}: not refused, yet the _lock gate was never reached`).toHaveBeenCalledTimes(1); + expect(await gate.mock.results[0]?.value).toBeNull(); + return 'admitted'; + } finally { + gate.mockRestore(); + } +} + +async function envelope(protocol: ObjectStackProtocolImplementation, type: string, name: string) { + const pick = (r: any) => ({ lock: r.lock, editable: r.editable, deletable: r.deletable }); + return { + byName: pick(await protocol.getMetaItem({ type, name })), + layered: pick(await protocol.getMetaItemLayered({ type, name })), + }; +} + +const ITEM_LOCKED = { refused: { code: 'ITEM_LOCKED', status: 403 } }; +const NOT_OVERRIDABLE = { refused: { code: 'NOT_OVERRIDABLE', status: 403 } }; + +afterEach(() => vi.restoreAllMocks()); + +describe('[#21694] pin 1 — a host-config kernel: the _lock door and the read agree', () => { + for (const lock of LOCKS) { + it(`an overlay view declaring _lock=${lock}: save and delete do what editable / deletable say`, async () => { + const name = `ov_${lock.replace('-', '_')}`; + const rows = [overlayRow('view', name, lock)]; + const { byName, layered } = await envelope(harness(undefined, rows), 'view', name); + expect(layered).toEqual(byName); + expect(byName.lock).toBe(lock); + + const save = await door(harness(undefined, rows), 'view', name, 'save'); + const del = await door(harness(undefined, rows), 'view', name, 'delete'); + expect(save).toEqual(byName.editable ? 'admitted' : ITEM_LOCKED); + expect(del).toEqual(byName.deletable ? 'admitted' : ITEM_LOCKED); + }); + } + + it('a publish of a _lock=full overlay item is refused there too (the shared write gate)', async () => { + const rows = [overlayRow('view', 'ov_pub', 'full')]; + const err: any = await settle(harness(undefined, rows).publishMetaItem({ type: 'view', name: 'ov_pub' })); + expect(err).toBeInstanceOf(Error); + expect({ code: err.code, status: err.status }).toEqual({ code: 'ITEM_LOCKED', status: 403 }); + }); + + it('a packaged app declaring _lock=full: the package door lets its removal through (#6960), and the _lock gate refuses it', async () => { + const artifacts: Artifacts = { app: { pkg_app: packaged('pkg_app', 'full') } }; + const { byName } = await envelope(harness(undefined, [], artifacts), 'app', 'pkg_app'); + expect(byName).toEqual({ lock: 'full', editable: false, deletable: false }); + expect(await door(harness(undefined, [], artifacts), 'app', 'pkg_app', 'delete')).toEqual(ITEM_LOCKED); + const harnessed = harness(undefined, [], artifacts); + // …and its save keeps the package door's code: the `_lock` gate ranks + // below it. (A valid app body, so the write reaches the repository, + // where this kernel answers that door.) + const gate = vi.spyOn(harnessed, 'assertLockAllowsWrite' as never); + expect(await door(harnessed, 'app', 'pkg_app', 'save', { name: 'pkg_app', label: 'pkg_app' })).toEqual(NOT_OVERRIDABLE); + expect(gate).not.toHaveBeenCalled(); + }); +}); + +describe('[#21694] pin 2 — the per-type locked count is the count of envelopes that read locked', () => { + // Three types, each mixing the limbs: a package-door lock with no `_lock` + // (flow, action), a declared `_lock` on a packaged item (app) and on an + // overlay row (view), and items that read unlocked. + const artifacts: Artifacts = { + flow: { pkg_flow: packaged('pkg_flow', 'none') }, + action: { pkg_action: packaged('pkg_action', 'none') }, + app: { pkg_app_locked: packaged('pkg_app_locked', 'full'), pkg_app_open: packaged('pkg_app_open', 'none') }, + view: { pkg_view: packaged('pkg_view', 'none', { object: 'account' }) }, + }; + const rows = [ + overlayRow('view', 'ov_full', 'full'), + overlayRow('view', 'ov_no_delete', 'no-delete'), + overlayRow('view', 'ov_open', 'none'), + overlayRow('flow', 'org_flow', 'none'), + ]; + + for (const environmentId of [undefined, ENV_ID]) { + const kernel = environmentId ? 'environment' : 'host-config'; + it(`stats[type].locked equals the envelopes' count, type by type (${kernel} kernel)`, async () => { + const protocol = harness(environmentId, rows, artifacts); + const measured: Record = {}; + for (const type of ['flow', 'action', 'app', 'view']) { + const diag = await protocol.getMetaDiagnostics({ type, severity: 'warning' }); + const listed = await protocol.getMetaItems({ type }); + const names = (listed.items as Array<{ name: string }>).map((i) => i.name).sort(); + const declared = (listed.items as Array<{ _lock?: unknown }>) + .filter((i) => i._lock !== undefined && i._lock !== 'none').length; + let envelopes = 0; + for (const name of names) { + if ((await protocol.getMetaItem({ type, name }) as any).lock !== 'none') envelopes += 1; + } + measured[type] = { tile: diag.stats[type]!.locked, envelopes, declared, names }; + } + for (const [type, m] of Object.entries(measured)) { + expect(m.tile, `${type}: tile ${m.tile} vs envelopes ${m.envelopes} over ${m.names.join(',')}`).toBe(m.envelopes); + } + // Lit control: the fixture reached every limb, so equality above is not 0 = 0. + // flow / action: one packaged item each, locked by the package door alone. + // app: one declared `_lock: full`, and one packaged app with none, which + // the package door refuses in place (save) but lets go (removal, #6960). + // view: overlay rows declaring `full` and `no-delete`; the packaged view + // and the unlocked row read open (`view` has an overlay channel). + expect(Object.fromEntries(Object.entries(measured).map(([t, m]) => [t, m.tile]))) + .toEqual({ flow: 1, action: 1, app: 2, view: 2 }); + // …and the package-door-only items are exactly the ones a count of the + // declared `_lock` missed — the disagreement this pin closes. + expect(Object.fromEntries(Object.entries(measured).map(([t, m]) => [t, m.declared]))) + .toEqual({ flow: 0, action: 0, app: 1, view: 2 }); + expect(measured.flow!.names).toEqual(['org_flow', 'pkg_flow']); + }); + } +}); + +describe('[#21694] pin 3 — an environment-bound kernel is unchanged', () => { + it('an overlay view declaring _lock=full reads locked and is refused ITEM_LOCKED on save and delete', async () => { + const rows = [overlayRow('view', 'ov_full', 'full')]; + const { byName, layered } = await envelope(harness(ENV_ID, rows), 'view', 'ov_full'); + expect(byName).toEqual({ lock: 'full', editable: false, deletable: false }); + expect(layered).toEqual(byName); + expect(await door(harness(ENV_ID, rows), 'view', 'ov_full', 'save')).toEqual(ITEM_LOCKED); + expect(await door(harness(ENV_ID, rows), 'view', 'ov_full', 'delete')).toEqual(ITEM_LOCKED); + }); + + it('an overlay view declaring _lock=no-delete: save passes the gate, delete is refused', async () => { + const rows = [overlayRow('view', 'ov_nd', 'no-delete')]; + const { byName } = await envelope(harness(ENV_ID, rows), 'view', 'ov_nd'); + expect(byName).toEqual({ lock: 'no-delete', editable: true, deletable: false }); + expect(await door(harness(ENV_ID, rows), 'view', 'ov_nd', 'save')).toBe('admitted'); + expect(await door(harness(ENV_ID, rows), 'view', 'ov_nd', 'delete')).toEqual(ITEM_LOCKED); + }); + + it('a packaged app declaring _lock=full keeps NOT_OVERRIDABLE on save and ITEM_LOCKED on delete — the same codes the host-config kernel now gives', async () => { + const artifacts: Artifacts = { app: { pkg_app: packaged('pkg_app', 'full') } }; + expect(await door(harness(ENV_ID, [], artifacts), 'app', 'pkg_app', 'save', { name: 'pkg_app', label: 'pkg_app' })).toEqual(NOT_OVERRIDABLE); + expect(await door(harness(ENV_ID, [], artifacts), 'app', 'pkg_app', 'delete')).toEqual(ITEM_LOCKED); + }); +}); diff --git a/packages/objectql/src/protocol-lock-enforcement.test.ts b/packages/objectql/src/protocol-lock-enforcement.test.ts index faf17ebce31..d99c0b56667 100644 --- a/packages/objectql/src/protocol-lock-enforcement.test.ts +++ b/packages/objectql/src/protocol-lock-enforcement.test.ts @@ -157,10 +157,16 @@ describe('ADR-0010 L3 lock enforcement — artifact-backed item', () => { }); }); -describe('ADR-0010 L3 lock enforcement — single-kernel bypass', () => { +// [#21694] This block used to pin the opposite: "environmentId=undefined +// bypasses L3 (control-plane bootstrap)". No ADR records that carve-out, and +// `environmentId === undefined` is also the CLI's host-config kernel, which +// serves end-user `/meta` writes (the inference #6710 refuted for the authoring +// gate) — while the read reported the same `_lock` as locked. The gate now +// answers on every topology. +describe('ADR-0010 L3 lock enforcement — no topology bypass', () => { afterEach(() => vi.clearAllMocks()); - it('environmentId=undefined bypasses L3 (control-plane bootstrap)', async () => { + it('environmentId=undefined (host-config) enforces L3 like an environment kernel', async () => { const registry = new SchemaRegistry({ multiTenant: false }); const mockEngine: any = { registry, @@ -172,17 +178,20 @@ describe('ADR-0010 L3 lock enforcement — single-kernel bypass', () => { count: vi.fn().mockResolvedValue(0), aggregate: vi.fn().mockResolvedValue([]), }; - // No environmentId — single-kernel / control plane. + // No environmentId — the host-config / single-kernel shape. const protocol = new ObjectStackProtocolImplementation( mockEngine, undefined, undefined, ); seedLockedArtifact(registry, 'view', 'case_grid', 'full'); - // Even with lock=full, single-kernel mode bypasses L3. - const save = await protocol.saveMetaItem({ + await expect(protocol.saveMetaItem({ type: 'view', name: 'case_grid', item: validView, - }); - expect(save.success).toBe(true); + })).rejects.toMatchObject({ code: 'ITEM_LOCKED', status: 403 }); + await expect(protocol.deleteMetaItem({ + type: 'view', name: 'case_grid', + })).rejects.toMatchObject({ code: 'ITEM_LOCKED', status: 403 }); + expect(mockEngine.update).not.toHaveBeenCalled(); + expect(mockEngine.delete).not.toHaveBeenCalled(); }); }); From 3c31eff8b4e97582484f1b0378a5b96b50f0f58b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 09:00:30 +0000 Subject: [PATCH 3/5] test(metadata-protocol): aim the delete re-wrap fixture's probe-read fault past the lock gate; add the changeset Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude --- .changeset/21694-lock-door-every-topology.md | 17 +++++++++++++++++ .../src/protocol.delete-rewrap-envelope.test.ts | 16 +++++++++++++++- 2 files changed, 32 insertions(+), 1 deletion(-) create mode 100644 .changeset/21694-lock-door-every-topology.md diff --git a/.changeset/21694-lock-door-every-topology.md b/.changeset/21694-lock-door-every-topology.md new file mode 100644 index 00000000000..956eb8ac605 --- /dev/null +++ b/.changeset/21694-lock-door-every-topology.md @@ -0,0 +1,17 @@ +--- +'@objectstack/metadata-protocol': minor +--- + +fix(metadata-protocol)!: the ADR-0010 `_lock` gate refuses on a host-config kernel too, and the diagnostics `locked` count reads the item envelope's derivation (#21694) + +Clause-②: no (narrowing) + + + +**BREAKING**: a `/meta` write that a host-config kernel used to accept can now be refused. A host-config kernel is one with no `environmentId`: the CLI's lightweight assembler boots one for a stack whose `plugins` are instantiated, which is how the showcase app runs. On such a kernel the item-level `_lock` gate never ran, so `saveMetaItem`, `publishMetaItem`, `rollbackMetaItem` and `deleteMetaItem` performed writes on items whose read envelope said `editable: false` or `deletable: false`. The gate now runs on every kernel, and answers the same `403 ITEM_LOCKED` an environment-bound kernel always gave. It ships as `minor` under the launch-window convention for accept-set narrowings. No export is added or removed. + +**What is refused now, on a host-config kernel.** A save, publish or rollback of an item whose effective `_lock` is `no-overlay` or `full`, and a delete of an item whose effective `_lock` is `no-delete` or `full`. The effective `_lock` is the packaged artifact's when one declares it, otherwise the stored row's. Each refusal writes its `denied` row to `sys_metadata_audit`, as on an environment kernel. The gate's own `sys_metadata` read fails closed there too: when it fails for any reason other than the table not being provisioned yet, the write is answered `503 SERVICE_UNAVAILABLE` before anything is written, where a delete used to reach the store and answer with the driver's code or a `500`. One shipped case reaches it: the platform's `setup`, `studio` and `account` apps declare `protection.lock: 'full'`, and a `DELETE /api/v1/meta/app/setup` used to pass the package door (removing a legacy app overlay is allowed) and then remove the app's overlay row, or answer success with nothing to remove. It now answers `403 ITEM_LOCKED`. The refusal names the lock and where it came from (`source=artifact` or `source=overlay`). If a host-config deployment relied on writing such an item: a lock a code package declares is changed in that package's source (`protection.lock`) and redeployed; a lock a stored row declares is held exactly as an environment-bound kernel holds it, so a row declaring `no-overlay` can still be deleted and saved again, and a row declaring `full` is no longer writable or removable through `/meta` on any kernel. + +**Unchanged.** Which code a packaged base answers: the `_lock` gate still ranks below the package door on both kernels, so a packaged item on a type with no overlay channel keeps `NOT_OVERRIDABLE` (or `ITEM_LOCKED` when the write names the read-only package) on a host-config kernel too. Every refusal, receipt and envelope on an environment-bound kernel. Both reads (`getMetaItem`, `getMetaItemLayered`), which already reported the declared `_lock` on every kernel. + +**The diagnostics count.** `getMetaDiagnostics().stats[type].locked` (the Studio directory's per-type tile) counted items with a declared `_lock`. It now counts items whose read envelope reports a lock other than `'none'`, from the same derivation the item read publishes. So an item that the package door refuses in place, such as a packaged flow or action with no `_lock` of its own, is counted, as its envelope has read locked since the previous release. The derivation reads the registry only, so the sweep makes no extra store read per item. diff --git a/packages/metadata-protocol/src/protocol.delete-rewrap-envelope.test.ts b/packages/metadata-protocol/src/protocol.delete-rewrap-envelope.test.ts index bf65edac3a4..9dbe56d4b77 100644 --- a/packages/metadata-protocol/src/protocol.delete-rewrap-envelope.test.ts +++ b/packages/metadata-protocol/src/protocol.delete-rewrap-envelope.test.ts @@ -171,6 +171,14 @@ function makeSession(opts: { for (const r of opts.seed ?? []) rows.set(r.id, r); const historyRows: Array> = []; const artifactKeys = new Set((opts.artifacts ?? []).map((a) => `${a.type}|${a.name}`)); + // [#21694] The ADR-0010 `_lock` gate reads `sys_metadata` on EVERY topology + // now, ahead of the probe read this file injects its fault into — and a + // failure of the gate's own read is answered fail-closed, `503 + // SERVICE_UNAVAILABLE` (#5706): that gate's contract, not this re-wrap's. + // So `failFindOne` arms only once the lock verdict is in, and the fault + // lands on the probe read, the seam this file pins (a gate that is never + // reached leaves the fault unarmed, and the case fails loudly). + let lockVerdictIn = false; const engine: any = { async findOne(table: string, o: { where: Record }) { @@ -179,7 +187,7 @@ function makeSession(opts: { return historyRows.find((h) => matchesWhere(h, o.where)) ?? null; } if (table !== 'sys_metadata') return null; - if (opts.failFindOne) opts.failFindOne(); + if (opts.failFindOne && lockVerdictIn) opts.failFindOne(); for (const row of rows.values()) if (matchesWhere(row as any, o.where)) return row; return null; }, @@ -238,6 +246,12 @@ function makeSession(opts: { () => new Map(), opts.environmentId, ) as any; + const lockGate = protocol.assertLockAllowsDelete.bind(protocol); + protocol.assertLockAllowsDelete = async (args: unknown) => { + const verdict = await lockGate(args); + lockVerdictIn = true; + return verdict; + }; return { protocol, rows, historyRows }; } From 5f67848b075090da149f648ec92adfe666b5bb92 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 09:12:26 +0000 Subject: [PATCH 4/5] test(objectql): re-aim four host-config fixtures that leaned on the retired _lock bypass Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude --- .../src/plugin.authoring-channel.test.ts | 8 +++ packages/objectql/src/protocol-meta.test.ts | 22 ++++++--- .../protocol-publish-package-drafts.test.ts | 6 ++- .../src/protocol-registry-shadow.test.ts | 49 +++++++++++++------ 4 files changed, 61 insertions(+), 24 deletions(-) diff --git a/packages/objectql/src/plugin.authoring-channel.test.ts b/packages/objectql/src/plugin.authoring-channel.test.ts index 57616893565..1931a529d1e 100644 --- a/packages/objectql/src/plugin.authoring-channel.test.ts +++ b/packages/objectql/src/plugin.authoring-channel.test.ts @@ -188,6 +188,13 @@ describe('#6710 — the authoring channel is threaded from plugin option to prot await kernel.use(new ObjectQLPlugin()); await kernel.bootstrap(); const protocol = kernel.getService('protocol') as any; + // [#21694] …and the storage driver serve.ts registers beside it (its + // step 2). The ADR-0010 `_lock` gate now reads `sys_metadata` on every + // topology, ahead of the authoring gate as on an environment kernel, and a + // read it cannot make is answered fail-closed (503) — so a kernel with no + // driver at all would be refused there and never show this gate. + const { driver, stores } = makeMemoryDriver(); + (kernel.getService('objectql') as ObjectQL).registerDriver(driver, true); expect( protocol.environmentId, @@ -196,6 +203,7 @@ describe('#6710 — the authoring channel is threaded from plugin option to prot const verdict = await publish(protocol); expect(verdict.refused, 'the end-user PUT /api/v1/meta/* surface must meet the gate').toBe(true); + expect(flowRows(stores), 'a gate that rejects after persisting is a log line').toEqual([]); // Both envelope halves (ADR-0112). Asserting only "it threw" would have // been green on the UNFIXED build too: that build reached the engine and // threw "No driver available", an Error whose code and status are both diff --git a/packages/objectql/src/protocol-meta.test.ts b/packages/objectql/src/protocol-meta.test.ts index 4d0bb5e4907..71041471ed3 100644 --- a/packages/objectql/src/protocol-meta.test.ts +++ b/packages/objectql/src/protocol-meta.test.ts @@ -439,16 +439,22 @@ describe('ObjectStackProtocolImplementation - Metadata Persistence', () => { } }); - it('should fail-fast with 500 when DB findOne is unavailable (ADR-0005)', async () => { + it('should fail-fast when DB findOne is unavailable (ADR-0005)', async () => { // ADR-0005 removed the silent in-memory degrade — DB write failures - // must surface as a 500 so callers know persistence failed. - // The new SysMetadataRepository path does not wrap errors; the raw - // DB error propagates directly. - mockEngine.findOne.mockRejectedValue(new Error('Connection refused')); + // must surface so callers know persistence failed. + // [#21694] The first read a save makes is the ADR-0010 `_lock` + // gate's, on this kernel as on an environment-bound one, and it + // fails CLOSED (#5706): a 503 that carries the driver error on + // `cause`, before anything is written. + const outage = new Error('Connection refused'); + mockEngine.findOne.mockRejectedValue(outage); - await expect( - protocol.saveMetaItem({ type: 'app', name: 'test_app', item: sampleApp }) - ).rejects.toThrow(/Connection refused/); + const err: any = await protocol.saveMetaItem({ type: 'app', name: 'test_app', item: sampleApp }) + .then(() => null, (e: unknown) => e); + expect(err).toBeInstanceOf(Error); + expect({ code: err.code, status: err.status }).toEqual({ code: 'SERVICE_UNAVAILABLE', status: 503 }); + expect(err.cause).toBe(outage); + expect(mockEngine.insert).not.toHaveBeenCalledWith('sys_metadata', expect.anything(), expect.anything()); }); it('should fail-fast with 500 when DB insert fails (ADR-0005)', async () => { diff --git a/packages/objectql/src/protocol-publish-package-drafts.test.ts b/packages/objectql/src/protocol-publish-package-drafts.test.ts index 7fb3ed808f8..ac8aa5c7e01 100644 --- a/packages/objectql/src/protocol-publish-package-drafts.test.ts +++ b/packages/objectql/src/protocol-publish-package-drafts.test.ts @@ -454,7 +454,11 @@ describe('protocol.publishMetaItem — seed self-apply', () => { function makePublishable(body: unknown) { const protocol = new ObjectStackProtocolImplementation({} as never); (protocol as any).ensureOverlayIndex = async () => {}; - (protocol as any).assertLockAllowsWrite = async () => null; + // [#21694] No lock on these items. Stubbed at `lockWriteRefusal`, the + // verdict the publish path asks (`promoteDraftForPublish`, since #8594), + // which `assertLockAllowsWrite` wraps: since the gate answers on every + // topology, this kernel reaches it too, and the double has no store. + (protocol as any).lockWriteRefusal = async () => null; (protocol as any).isArtifactBacked = () => false; (protocol as any).applyObjectRegistryMutation = () => {}; (protocol as any).ensureObjectStorage = async () => {}; diff --git a/packages/objectql/src/protocol-registry-shadow.test.ts b/packages/objectql/src/protocol-registry-shadow.test.ts index abde1591f61..848e4e5f8a2 100644 --- a/packages/objectql/src/protocol-registry-shadow.test.ts +++ b/packages/objectql/src/protocol-registry-shadow.test.ts @@ -5,8 +5,8 @@ * * Regression suite for the "registry pollution" bug: on a control-plane * kernel (`environmentId === undefined`), PUT /meta/app/ on a - * `_lock: full` artifact-backed app succeeded (the L3 gate is - * intentionally bypassed there), and the next GET list hydrated the + * `_lock: full` artifact-backed app succeeded (the L3 gate was bypassed + * there until #21694), and the next GET list hydrated the * overlay body into the SchemaRegistry under the PLAIN key — shadowing * the packaged artifact registered under `:`. Every * envelope reader (`lookupArtifactItem` / `getEffectiveLock` / @@ -170,8 +170,8 @@ function makeStubDriver() { return { driver, stores }; } -/** Artifact app shipped by a code package with a hard lock. */ -function artifactApp() { +/** Artifact app shipped by a code package with a lock (`full` unless a case needs removal to pass the L3 gate). */ +function artifactApp(lock: 'full' | 'no-overlay' = 'full') { return { name: 'setup', label: 'Setup', @@ -179,7 +179,7 @@ function artifactApp() { _packageId: PKG, _packageVersion: '1.0.0', _provenance: 'package', - _lock: 'full', + _lock: lock, _lockReason: 'Core admin UI shipped by the platform package.', }; } @@ -223,17 +223,32 @@ describe('registry shadow — control-plane PUT → GET → DELETE keeps the art await engine.init(); engine.registry.registerObject(sysMetadataObject); engine.registry.registerObject(sysMetadataHistoryObject); - engine.registry.registerItem('app', artifactApp(), 'name', PKG); - // No environmentId — single-kernel / control-plane mode, where the - // L3 lock gate is bypassed and the GET list hydrates overlay rows - // into the process-wide registry. + // No environmentId — single-kernel / control-plane mode, where the GET + // list hydrates overlay rows into the process-wide registry. The + // artifact is installed by each case (see `overlayRowThenLockedArtifact`). protocol = new ObjectStackProtocolImplementation(engine); }); afterEach(resetEnvHatch); - it('GET list while the overlay row exists: overlay content wins, artifact envelope wins', async () => { + /** + * [#21694] The overlay row these cases are about can no longer be written + * through the door while the artifact's `_lock` stands: this kernel's L3 + * gate refuses it now, as an environment kernel's always did. So the row is + * written first and the package's lock arrives after it — the order the + * envelope graft exists for (an overlay copy that pre-dates the artifact's + * protection declaration, ADR-0010 §3.3). + */ + async function overlayRowThenLockedArtifact(lock: 'full' | 'no-overlay'): Promise { await protocol.saveMetaItem({ type: 'app', name: 'setup', item: { ...overlayBody } }); + engine.registry.registerItem('app', artifactApp(lock), 'name', PKG); + } + + it('GET list while the overlay row exists: overlay content wins, artifact envelope wins', async () => { + await overlayRowThenLockedArtifact('full'); + // The lock now holds on this kernel too: a further PUT is refused. + await expect(protocol.saveMetaItem({ type: 'app', name: 'setup', item: { ...overlayBody, label: 'Again' } })) + .rejects.toMatchObject({ code: 'ITEM_LOCKED', status: 403 }); const res = await protocol.getMetaItems({ type: 'app' }); const setup = findByName((res as any).items, 'setup'); @@ -245,7 +260,7 @@ describe('registry shadow — control-plane PUT → GET → DELETE keeps the art }); it('the hydrated plain-key shadow itself carries the artifact envelope', async () => { - await protocol.saveMetaItem({ type: 'app', name: 'setup', item: { ...overlayBody } }); + await overlayRowThenLockedArtifact('full'); await protocol.getMetaItems({ type: 'app' }); // triggers hydration // Registry-direct read (what nav/UI code does) must not see a @@ -258,7 +273,9 @@ describe('registry shadow — control-plane PUT → GET → DELETE keeps the art }); it('DELETE (reset) heals the registry: artifact value and lock are back without a restart', async () => { - await protocol.saveMetaItem({ type: 'app', name: 'setup', item: { ...overlayBody } }); + // `no-overlay`: a lock under which removal passes the L3 gate (`full` + // refuses it on every kernel now, #21694). + await overlayRowThenLockedArtifact('no-overlay'); await protocol.getMetaItems({ type: 'app' }); // pollute via hydration const del = await protocol.deleteMetaItem({ type: 'app', name: 'setup' }); @@ -268,20 +285,22 @@ describe('registry shadow — control-plane PUT → GET → DELETE keeps the art // Registry-direct read resolves the packaged artifact again. const direct: any = engine.registry.getItem('app', 'setup'); expect(direct.label).toBe('Setup'); - expect(direct._lock).toBe('full'); + expect(direct._lock).toBe('no-overlay'); expect(direct._packageId).toBe(PKG); // And the protocol list surface agrees. const res = await protocol.getMetaItems({ type: 'app' }); const setup = findByName((res as any).items, 'setup'); expect(setup.label).toBe('Setup'); - expect(setup._lock).toBe('full'); + expect(setup._lock).toBe('no-overlay'); expect(setup._packageId).toBe(PKG); }); it('a second DELETE self-heals pre-existing pollution even with no overlay row', async () => { // Simulate the pre-fix world: overlay body sits on the plain key // with a stripped envelope, and the sys_metadata row is gone. + // (`no-overlay`, as in the case above: removal passes the L3 gate.) + engine.registry.registerItem('app', artifactApp('no-overlay'), 'name', PKG); engine.registry.registerItem('app', { ...overlayBody }, 'name'); const del = await protocol.deleteMetaItem({ type: 'app', name: 'setup' }); @@ -291,7 +310,7 @@ describe('registry shadow — control-plane PUT → GET → DELETE keeps the art // …but the registry shadow is healed anyway. const direct: any = engine.registry.getItem('app', 'setup'); expect(direct.label).toBe('Setup'); - expect(direct._lock).toBe('full'); + expect(direct._lock).toBe('no-overlay'); }); }); From e567556d97ce66a4beb2a1abe2e20e301756b1e3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 09:39:59 +0000 Subject: [PATCH 5/5] chore(gates): record the new pinned findOne double in the engine-double ledger Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude --- scripts/engine-double-contract.pinned.json | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index 36690341b8d..722eba1865b 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -801,6 +801,11 @@ "verb": "update", "pinned": 1 }, + { + "file": "packages/metadata-protocol/src/protocol.lock-door-read-agree.test.ts", + "verb": "findOne", + "pinned": 1 + }, { "file": "packages/metadata-protocol/src/protocol.lock-gate-canonical-type.test.ts", "verb": "findOne",