From d71dddb9969d02b0786783f87dc4913393a9ed81 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 3 Sep 2026 02:46:58 +0000 Subject: [PATCH 1/4] test(packages): gate that a package door's allowlist cannot silently drop a stamped key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both `/packages` doors project the installed-package record through a hand-written field allowlist. The trade that bought was explicit: drift shows up as a missing field, never a 500. Within one day of it landing, an ADR-0070 D2 `writable` verdict started reaching both doors and both would have dropped it — a 200 with the field simply absent. One door was saved by someone reading a sibling pin, the other by a merge conflict. The field-specific pins that followed cover `writable` and nothing else. These two gates cover the general case, deriving both sides from real code rather than a hand-kept key list: - REST door: `served ⊇ getMetaItems({type:'package'}) keys`, measured through the real `ObjectStackProtocolImplementation` over a real `SchemaRegistry`. - runtime door: `served ⊇ record keys`, plus a set-equality register for the keys the door stamps AFTER the projection — measured as `served − record`, so a reorder empties it and reds. The two doors solved the near-miss differently (the REST allowlist contains `writable`; the runtime one deliberately does not and orders instead), so the invariants are stated separately rather than assumed symmetric. Only hand-kept artifacts are the annotated exclusion and stamp registers, both compared loudly. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza --- .../package-door-producer-key-carry.test.ts | 370 ++++++++++++++++++ .../package-door-producer-key-carry.test.ts | 334 ++++++++++++++++ 2 files changed, 704 insertions(+) create mode 100644 packages/rest/src/package-door-producer-key-carry.test.ts create mode 100644 packages/runtime/src/domains/package-door-producer-key-carry.test.ts diff --git a/packages/rest/src/package-door-producer-key-carry.test.ts b/packages/rest/src/package-door-producer-key-carry.test.ts new file mode 100644 index 0000000000..97e01c67c0 --- /dev/null +++ b/packages/rest/src/package-door-producer-key-carry.test.ts @@ -0,0 +1,370 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * GATE — the REST `/packages` door's field allowlist must not silently drop a + * key the PRODUCER stamps. + * + * ## The defect this exists to catch + * + * `REGISTRY_PACKAGE_RESPONSE_FIELDS` (`package-routes.ts`) is a hand-written + * allowlist, and that was a deliberate trade: a newly declared or + * producer-stamped field becomes an explicit decision at this door, and drift + * shows up as a **missing field** rather than as the `500 Converting circular + * structure to JSON` the projection replaced. + * + * The other half of that trade is this file. Within one day of the allowlist + * landing, the protocol started stamping an ADR-0070 D2 `writable` verdict on + * every `getMetaItems({ type: 'package' })` row. The allowlist did not list it, + * so the door would have answered **200 with the verdict simply absent** — no + * 500, no red, and nothing on the wire a consumer could tell apart from "this + * package has no verdict". It was caught by someone happening to read a sibling + * pin. That is not a mechanism. + * + * ⚠️ `writable` itself is now pinned by name in + * `package-list-writable-carry.test.ts`, so the *known* field is covered. What + * was missing — and is what this file supplies — is the GENERAL case: **nothing + * generalised to the NEXT stamped key.** The next ADR-0070-style verdict lands + * with exactly the same silence. + * + * ## The invariant, and why it is derived rather than listed + * + * served key set ⊇ producer key set − DELIBERATELY_NOT_SERVED + * + * Both sides are MEASURED by running real code in this test: + * + * - the producer side is the real `ObjectStackProtocolImplementation` + * (`getMetaItems({ type: 'package' })`) over a real `SchemaRegistry` — the + * same path production reads, including the `writable` stamp and the + * `decorateMetadataItem` graft; + * - the served side is this door's real `GET /api/v1/packages` output. + * + * ⛔ Neither side is a hand-written key list. A fixture that hand-listed the + * producer's keys would be a THIRD copy of the same truth and would drift + * alongside the two it is meant to compare. The only hand-kept artifact here is + * {@link DELIBERATELY_NOT_SERVED} — an explicit, annotated exclusion register, + * which the card requires to be exactly that: a decision that must be written + * down, never an omission that accumulates in silence. + * + * ⛔ The allowlist is NOT derived from `packages/spec`. That was weighed and + * rejected on the originating card (it makes the published surface a side + * effect of a schema edit, and adds a spec import edge to `@objectstack/rest`). + * This file is the detector that makes the hand-list honest — not a re-opening + * of that decision. + * + * ## Why the invariant is `⊇` and not `=` + * + * This door must KEEP dropping an undeclared member that leaks onto a registry + * item — that is the whole point of projecting rather than spreading, and a + * set-equality gate would force such a member back onto the wire and re-open + * the `500`. So the gate is one-directional, and the fixture installs its + * packages through the real `SchemaRegistry.installPackage`: over a clean + * install the producer's key set IS "declared record fields + producer stamps", + * which is exactly the set that must survive. A future producer key that + * genuinely should not be published therefore arrives here as a red, and is + * answered by writing it into {@link DELIBERATELY_NOT_SERVED} with a reason — + * an explicit decision at the door, which is what the card asked for. The last + * test in this file pins the drop so the two claims stay visibly compatible. + * + * ## The twin door has a DIFFERENT invariant — do not assume symmetry + * + * The runtime dispatcher twin (`packages/runtime/src/domains/packages.ts`) + * solved the same near-miss the other way: `writable` is NOT an allowlist + * member there, and the door stamps it AFTER the projection instead. Asserting + * "the allowlist contains every stamped key" over there would red on a correct + * door. Its own half of this gate is + * `packages/runtime/src/domains/package-door-producer-key-carry.test.ts`, which + * states the ordering invariant instead. Two pins, one shape — see that file's + * header for why they are not one file. + */ + +import { describe, it, expect } from 'vitest'; +import { SchemaRegistry } from '@objectstack/objectql'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import type { RouteHandler } from '@objectstack/spec/contracts'; +import { registerPackageRoutes } from './package-routes.js'; + +const PKGS = '/api/v1/packages'; + +/** + * Keys the producer puts on a package row that this door deliberately does NOT + * serve. **Explicit and annotated by construction** — an entry added here is a + * published-surface decision someone wrote down, which is the whole difference + * between this register and the silent omission it replaces. + * + * Empty today: every key the real producer stamps on a package row is carried. + * Measured, not assumed — {@link producerKeysOf} reads the real + * `getMetaItems({ type: 'package' })` output, and the assertion below fails + * with the offending key names when that stops being true. + * + * ⛔ Adding a key here to make a red test green is the defect one level up. The + * question an entry must answer is "why must this door withhold it?", and the + * answer belongs in the comment beside it. + */ +const DELIBERATELY_NOT_SERVED: readonly string[] = []; + +/** The engine's own `actionActivation -> store -> engine` cycle, reproduced. */ +function cyclicEngine(): Record { + const engine: Record = { name: '_ObjectQL' }; + const store: Record = { name: 'ObjectStoreActionActivationStore', engine }; + engine.actionActivation = { name: 'ActionActivationProjection', store }; + return engine; +} + +/** A host-constructed connector plugin that takes the engine on init. */ +class FakeConnectorPlugin { + name = 'connector-rest'; + engine: unknown; + init(engine: unknown) { this.engine = engine; } +} + +/** Booted app package, explicit `scope: 'project'` — the producer says read-only. */ +const CODE_PROJECT = 'com.example.showcase'; +/** Platform-delivered plugin package. */ +const SYSTEM_SCOPED = 'com.objectstack.setup'; +/** Studio-created database base: installed, never booted, scope-less. */ +const DB_BASE = 'com.acme.mybase'; + +/** + * A registry in the showcase's shape, built through the REAL + * `SchemaRegistry.installPackage` — so the records under test are the records + * production holds, not a literal someone typed next to the assertion. + */ +function realRegistry(): SchemaRegistry { + const registry = new SchemaRegistry({ multiTenant: false, collisionPolicy: 'error' }); + (registry as unknown as { logLevel: string }).logLevel = 'silent'; + + const plugin = new FakeConnectorPlugin(); + registry.installPackage({ + id: CODE_PROJECT, + name: 'Showcase', + namespace: 'showcase', + version: '0.3.16', + type: 'app', + scope: 'project', + description: 'Kitchen-sink showcase workspace', + objects: [{ name: 'invoice', fields: { total: { type: 'currency' } } }], + apps: [{ name: 'showcase', label: 'Showcase' }], + plugins: [plugin], + } as never); + // Init AFTER install — the measured ordering: the manifest serialised cleanly + // during boot and only became cyclic once the plugins came up. + plugin.init(cyclicEngine()); + + registry.installPackage({ + id: SYSTEM_SCOPED, name: 'Setup', namespace: 'setup', version: '9.3.0', + type: 'plugin', scope: 'system', + } as never); + + registry.installPackage({ + id: DB_BASE, name: 'My Base', namespace: 'mybase', version: '1.0.0', type: 'app', + } as never); + + return registry; +} + +/** + * The real producer, over that registry. `manifests` is what `ObjectQL.registerApp` + * records for every package of a loaded artifact — the ADR-0070 D2 predicate + * reads it FIRST, so it is what separates a booted (read-only) package from a + * Studio-created (writable) base and makes the `writable` stamp non-constant. + */ +function realProducer(registry: SchemaRegistry): ObjectStackProtocolImplementation { + const engine: Record = { + registry, + manifests: new Map([ + [CODE_PROJECT, registry.getPackage(CODE_PROJECT)?.manifest], + [SYSTEM_SCOPED, registry.getPackage(SYSTEM_SCOPED)?.manifest], + ]), + // No `sys_metadata` overlay in this fixture: the subject is the registry + // half's key set, and an overlay row would only add rows, not keys. + find: async () => [], + findOne: async () => null, + }; + return new ObjectStackProtocolImplementation(engine as never, () => new Map()); +} + +type ProducerRow = Record & { manifest?: { id?: unknown } }; + +/** The row id, keyed the way both the producer and the door key it. */ +function rowId(row: ProducerRow): string | undefined { + const fromManifest = row?.manifest?.id; + if (typeof fromManifest === 'string') return fromManifest; + return typeof row.id === 'string' ? row.id : undefined; +} + +/** MEASURE the producer's key set — never a literal. */ +async function producerKeysOf( + protocol: ObjectStackProtocolImplementation, +): Promise>> { + const res = await protocol.getMetaItems({ type: 'package' }); + const out = new Map>(); + for (const item of (res.items ?? []) as ProducerRow[]) { + const id = rowId(item); + if (id) out.set(id, new Set(Object.keys(item))); + } + return out; +} + +interface Captured { status: number; body: any } + +/** A durable half that finds nothing, so only the registry half is exercised. */ +const EMPTY_DB = { list: async () => [], get: async () => null }; + +function mount(protocol: ObjectStackProtocolImplementation) { + const routes = new Map(); + const server = { + get: (p: string, h: RouteHandler) => { routes.set(`GET:${p}`, h); }, + post: (p: string, h: RouteHandler) => { routes.set(`POST:${p}`, h); }, + put: () => {}, + delete: (p: string, h: RouteHandler) => { routes.set(`DELETE:${p}`, h); }, + patch: () => {}, + use: () => {}, + listen: async () => {}, + close: async () => {}, + } as never; + // The authorization gate (#7033 / #7023) is not this file's subject. + registerPackageRoutes(server, (() => EMPTY_DB) as never, '/api/v1', { + resolveExecutionContext: async () => ({ + userId: 'u_pkg', systemPermissions: ['manage_metadata', 'studio.access', 'setup.access'], + }), + // The REAL producer, bound as the door's protocol seam. + protocol: { getMetaItems: (req: never) => protocol.getMetaItems(req) }, + } as never); + return routes; +} + +async function drive( + routes: Map, + method: string, + path: string, + req: Record = {}, +): Promise { + const handler = routes.get(`${method}:${path}`); + if (!handler) throw new Error(`no handler for ${method} ${path}`); + const captured: Captured = { status: 200, body: undefined }; + const res: any = { + json(data: any) { captured.body = data; }, + send() {}, + status(code: number) { captured.status = code; return res; }, + header() { return res; }, + }; + await handler( + { params: {}, query: {}, body: undefined, headers: {}, method, path, ...req } as never, + res, + ); + return captured; +} + +/** + * THE DETECTOR. Shared in shape with the runtime twin: the keys a door drops + * are `producer − served − excluded`, and a non-empty answer is the defect. + * + * Returns the dropped keys rather than asserting, so the caller can name the + * row in the failure message — a bare "expected [] to equal ['writable']" does + * not say which package or which door. + */ +function droppedKeys( + producer: ReadonlySet, + served: ReadonlySet, + excluded: readonly string[], +): string[] { + const exempt = new Set(excluded); + return [...producer].filter((k) => !served.has(k) && !exempt.has(k)).sort(); +} + +describe('GATE: REST GET /packages carries every key the producer stamps', () => { + it('control: the producer really does stamp keys the RECORD does not declare', async () => { + // ANTI-VACUITY. If the producer stamped nothing beyond the stored record, + // the coverage assertion below could pass over an allowlist that had never + // been asked a hard question. `writable` (ADR-0070 D2) is the stamp the + // near-miss was about, so its presence is what makes this gate load-bearing + // — and its absence would mean the producer changed under us, which is a + // decision, not a green. + const registry = realRegistry(); + const perRow = await producerKeysOf(realProducer(registry)); + + expect([...perRow.keys()].sort()).toEqual([DB_BASE, CODE_PROJECT, SYSTEM_SCOPED].sort()); + + const recordKeys = new Set(Object.keys(registry.getPackage(CODE_PROJECT) as object)); + const stamped = [...perRow.get(CODE_PROJECT)!].filter((k) => !recordKeys.has(k)); + expect(stamped).toContain('writable'); + }); + + it('every producer key survives to the wire, for every package', async () => { + // THE GATE. Remove a stamped key from `REGISTRY_PACKAGE_RESPONSE_FIELDS` — + // or land a new producer stamp without adding it — and this reds with the + // key's own name, instead of shipping a 200 with the field absent. + const registry = realRegistry(); + const protocol = realProducer(registry); + const perRow = await producerKeysOf(protocol); + + const res = await drive(mount(protocol), 'GET', PKGS); + expect(res.status).toBe(200); + + const served: ProducerRow[] = res.body?.data?.packages ?? []; + expect(served.length).toBe(perRow.size); + + const routes = mount(protocol); + const report: string[] = []; + for (const [id, producerKeys] of perRow) { + const row = served.find((p) => rowId(p) === id); + expect(row, `package ${id} vanished from the response entirely`).toBeDefined(); + const dropped = droppedKeys(producerKeys, new Set(Object.keys(row as object)), DELIBERATELY_NOT_SERVED); + if (dropped.length) report.push(`list ${id}: ${dropped.join(', ')}`); + + // The DETAIL door runs the same producer through the same projection, and + // is the other place a dropped key would ship as a 200. + const one = await drive(routes, 'GET', `${PKGS}/:id`, { params: { id } }); + expect(one.status, `GET ${PKGS}/${id}`).toBe(200); + const detail = one.body?.data?.package as ProducerRow | undefined; + expect(detail, `package ${id} vanished from the detail response`).toBeDefined(); + const detailDropped = droppedKeys(producerKeys, new Set(Object.keys(detail as object)), DELIBERATELY_NOT_SERVED); + if (detailDropped.length) report.push(`detail ${id}: ${detailDropped.join(', ')}`); + } + + // The failure text names the door, the package and the key, because the + // reader of a red here is someone who just added a producer stamp and needs + // to be told which allowlist to decide about. + expect( + report, + 'REST GET /packages dropped producer-stamped key(s). Either add them to ' + + '`REGISTRY_PACKAGE_RESPONSE_FIELDS` in package-routes.ts, or record the ' + + 'withholding in `DELIBERATELY_NOT_SERVED` in this file with the reason.', + ).toEqual([]); + }); + + it('control: the detector reports a dropped key rather than passing vacuously', async () => { + // Proves the assertion above can FAIL, without mutating source. The door is + // real and so is the drop: an undeclared key on the producer's row is + // exactly what the allowlist deletes, and the detector must say so. + const registry = realRegistry(); + const protocol = realProducer(registry); + const real = await producerKeysOf(protocol); + + const res = await drive(mount(protocol), 'GET', PKGS); + const row = (res.body?.data?.packages ?? []).find((p: ProducerRow) => rowId(p) === CODE_PROJECT); + const servedKeys = new Set(Object.keys(row as object)); + + // A hypothetical next verdict, stamped by the producer and not yet decided + // about at this door. + const withNextStamp = new Set([...real.get(CODE_PROJECT)!, 'nextVerdict']); + expect(droppedKeys(withNextStamp, servedKeys, DELIBERATELY_NOT_SERVED)).toEqual(['nextVerdict']); + // …and the exclusion register is the one way to make that green again. + expect(droppedKeys(withNextStamp, servedKeys, ['nextVerdict'])).toEqual([]); + }); + + it('the projection still drops an undeclared LIVE member — the gate does not undo it', async () => { + // The coverage assertion is one-directional (⊇), on purpose: this door must + // keep degrading an unserializable member to a missing field. A gate written + // as set EQUALITY would have forced that member back onto the wire and + // re-opened the 500. + const registry = realRegistry(); + (registry.getPackage(CODE_PROJECT) as Record).liveEngineHandle = cyclicEngine(); + + const res = await drive(mount(realProducer(registry)), 'GET', PKGS); + expect(res.status).toBe(200); + expect(() => JSON.stringify(res.body)).not.toThrow(); + const served: ProducerRow[] = res.body?.data?.packages ?? []; + expect(served.some((p) => 'liveEngineHandle' in p)).toBe(false); + }); +}); diff --git a/packages/runtime/src/domains/package-door-producer-key-carry.test.ts b/packages/runtime/src/domains/package-door-producer-key-carry.test.ts new file mode 100644 index 0000000000..5743fe6563 --- /dev/null +++ b/packages/runtime/src/domains/package-door-producer-key-carry.test.ts @@ -0,0 +1,334 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * GATE — the runtime `/packages` door's field allowlist must not silently drop + * a key, whether the key comes from the record it projects or from the door's + * own post-projection stamp. + * + * ## The defect this exists to catch + * + * `INSTALLED_PACKAGE_RESPONSE_FIELDS` (`packages.ts`) is a hand-written + * allowlist, and that was a deliberate trade: an undeclared member on the + * registry item degrades to a field the response never mentions instead of + * failing the whole list with `500 Converting circular structure to JSON`. The + * price is that drift arrives as a **missing field** — a 200 with the key + * simply absent, no red anywhere. + * + * Within one day of that allowlist landing, the platform started computing an + * ADR-0070 D2 `writable` verdict for every package row. This door survived by + * ORDERING: it projects first and stamps second. Had the two lines been the + * other way round, the projection would have deleted the verdict — silently. + * It was caught by a merge conflict. That is not a mechanism. + * + * ⚠️ `writable` itself is now pinned by name in + * `packages-serializable-response.test.ts`, so the *known* field is covered. + * What was missing — and is what this file supplies — is the GENERAL case: + * **nothing generalised to the next stamped or declared key.** + * + * ## Two invariants, because this door has two ways to lose a key + * + * 1. **Coverage.** Every key on the record the door projects must reach the + * wire: `served ⊇ record − DELIBERATELY_NOT_SERVED`. A field added to the + * installed-package record and not to the allowlist reds here. + * 2. **Stamps.** The keys the door adds on top of the projection — measured as + * `served − record`, never listed — must set-equal + * {@link DOOR_COMPUTED_STAMPS}. A reorder that puts the stamp BEFORE the + * projection empties that measured set and reds; a new stamp grows it and + * reds until someone records the decision. + * + * `DOOR_COMPUTED_STAMPS` is a hand-kept register, and that is the point rather + * than a compromise: it is a **set-equality** register, so drift makes it RED, + * where the allowlist it guards makes drift SILENT. Swapping a silent list for + * a loud one is the whole mechanism this card asked for. + * + * ## The twin door has a DIFFERENT invariant — do not assume symmetry + * + * The REST twin (`packages/rest/src/package-routes.ts`) solved the same + * near-miss the other way: its producer is `getMetaItems({ type: 'package' })`, + * which stamps `writable` UPSTREAM, so over there the fix was to add the key to + * the allowlist and the invariant is "the allowlist contains every stamped + * key". Asserting that here would red on a correct door — this door's allowlist + * deliberately does not contain `writable`. Its half of this gate is + * `packages/rest/src/package-door-producer-key-carry.test.ts`. + * + * ## Why two files rather than one detector + * + * Measured, not assumed. A single file would have to reach the other package's + * door, and both directions are worse than the split: + * + * - From here, `@objectstack/rest` resolves to its **`dist/`** (no vitest + * alias maps it to source), which would make this gate's verdict a function + * of build state — a stale `dist` reds or greens for reasons that have + * nothing to do with the checkout. It would also GROW + * `KNOWN_UNALIASED_TEST_IMPORTS` in `scripts/check-test-source-alias.mjs`, + * a SHRINK-ONLY registry that lists no `@objectstack/rest` entry for this + * package today. + * - `@objectstack/rest` cannot import `@objectstack/runtime` at all — the + * dependency runs the other way. + * + * So: two pins, one shape. Each imports its own door as SOURCE, which is also + * what lets a reviewer ablate one allowlist and watch exactly one gate go red. + */ + +import { describe, it, expect } from 'vitest'; +import { SchemaRegistry } from '@objectstack/objectql'; +import { HttpDispatcher } from '../http-dispatcher.js'; + +/** + * Keys on the installed-package record this door deliberately does NOT serve. + * **Explicit and annotated by construction** — an entry here is a + * published-surface decision someone wrote down. + * + * Empty today: the allowlist carries every field the record declares. + * + * ⛔ Adding a key here to make a red test green is the defect one level up. The + * question an entry must answer is "why must this door withhold it?", and the + * answer belongs in the comment beside it. + */ +const DELIBERATELY_NOT_SERVED: readonly string[] = []; + +/** + * Keys this door computes ITSELF and adds after the projection — the set the + * allowlist deliberately does not contain, so ORDER is the only thing keeping + * them on the wire. + * + * - `writable` — the ADR-0070 D2 writability verdict (`withWritableVerdict`). + * Not a record field: it is a property of the running engine, recomputed per + * read and never stored. `isWritablePackage` is the same predicate the + * authoring and lifecycle gates use. + * + * Compared by SET EQUALITY against the measured `served − record`, so both + * directions are loud: a stamp that stops reaching the wire (the reorder this + * card is about) and a stamp that arrives without a decision. + */ +const DOOR_COMPUTED_STAMPS: readonly string[] = ['writable']; + +/** Authenticated caller holding the ADR-0106 D4 read capability. */ +const reader = (): any => ({ + request: {}, + environmentId: 'platform', + executionContext: { userId: 'u_admin', isSystem: false, systemPermissions: ['studio.access'] }, +}); + +/** The engine's own `actionActivation -> store -> engine` cycle, reproduced. */ +function cyclicEngine(): Record { + const engine: Record = { name: '_ObjectQL' }; + const store: Record = { name: 'ObjectStoreActionActivationStore', engine }; + engine.actionActivation = { name: 'ActionActivationProjection', store }; + return engine; +} + +/** A host-constructed connector plugin that takes the engine on init. */ +class FakeConnectorPlugin { + name = 'connector-runtime'; + engine: unknown; + init(engine: unknown) { this.engine = engine; } +} + +/** Booted app package — the ADR-0070 predicate says read-only. */ +const CODE_PROJECT = 'com.example.showcase'; +/** Platform-delivered plugin package. */ +const SYSTEM_SCOPED = 'com.objectstack.setup'; +/** Studio-created database base: installed, never booted, scope-less. */ +const DB_BASE = 'com.acme.mybase'; + +/** + * A registry in the showcase's shape, built through the REAL + * `SchemaRegistry.installPackage` — so the records under test are the records + * production holds, not a literal someone typed next to the assertion. A + * fixture that hand-listed the record's keys would be a third copy of the same + * truth and would drift with the two it is meant to compare. + */ +function realRegistry(): SchemaRegistry { + const registry = new SchemaRegistry({ multiTenant: false, collisionPolicy: 'error' }); + (registry as unknown as { logLevel: string }).logLevel = 'silent'; + + const plugin = new FakeConnectorPlugin(); + registry.installPackage({ + id: CODE_PROJECT, + name: 'Showcase', + namespace: 'showcase', + version: '0.3.16', + type: 'app', + scope: 'project', + description: 'Kitchen-sink showcase workspace', + objects: [{ name: 'invoice', fields: { total: { type: 'currency' } } }], + apps: [{ name: 'showcase', label: 'Showcase' }], + plugins: [plugin], + } as never); + // Init AFTER install — the measured ordering: the manifest serialised + // cleanly during boot and only became cyclic once the plugins came up. + plugin.init(cyclicEngine()); + + registry.installPackage({ + id: SYSTEM_SCOPED, name: 'Setup', namespace: 'setup', version: '9.3.0', + type: 'plugin', scope: 'system', + } as never); + + registry.installPackage({ + id: DB_BASE, name: 'My Base', namespace: 'mybase', version: '1.0.0', type: 'app', + } as never); + + return registry; +} + +/** + * The dispatcher over that registry. `manifests` is what `ObjectQL.registerApp` + * records for every package of a loaded artifact — the ADR-0070 D2 predicate + * reads it FIRST, so it is what makes the `writable` stamp a real computation + * rather than a constant. + */ +function dispatcherOver(registry: SchemaRegistry): HttpDispatcher { + const qlService: any = { + registry, + manifests: new Map([ + [CODE_PROJECT, registry.getPackage(CODE_PROJECT)?.manifest], + [SYSTEM_SCOPED, registry.getPackage(SYSTEM_SCOPED)?.manifest], + ]), + }; + const kernel: any = { + context: { getService: (n: string) => (n === 'objectql' ? qlService : null) }, + }; + return new HttpDispatcher(kernel); +} + +type Row = Record & { manifest?: { id?: unknown } }; + +const rowId = (row: Row): string | undefined => { + const fromManifest = row?.manifest?.id; + if (typeof fromManifest === 'string') return fromManifest; + return typeof row.id === 'string' ? row.id : undefined; +}; + +/** MEASURE the record's key set from the real registry — never a literal. */ +const recordKeysOf = (registry: SchemaRegistry, id: string): Set => + new Set(Object.keys(registry.getPackage(id) as object)); + +/** + * THE DETECTOR. Shared in shape with the REST twin: the keys a door drops are + * `expected − served − excluded`, and a non-empty answer is the defect. + * + * Returns the dropped keys rather than asserting, so the caller can name the + * row in the failure message. + */ +function droppedKeys( + expected: ReadonlySet, + served: ReadonlySet, + excluded: readonly string[], +): string[] { + const exempt = new Set(excluded); + return [...expected].filter((k) => !served.has(k) && !exempt.has(k)).sort(); +} + +async function listRows(registry: SchemaRegistry): Promise { + const r = await dispatcherOver(registry).handlePackages('/', 'GET', undefined, {}, reader()); + expect(r.response?.status).toBe(200); + return (r.response?.body?.data?.packages ?? []) as Row[]; +} + +async function detailRow(registry: SchemaRegistry, id: string): Promise { + const r = await dispatcherOver(registry).handlePackages(`/${id}`, 'GET', undefined, {}, reader()); + expect(r.response?.status).toBe(200); + return (r.response?.body?.data ?? {}) as Row; +} + +describe('GATE: runtime /packages carries every declared and stamped key', () => { + it('control: the registry record is non-trivial and carries no verdict of its own', async () => { + // ANTI-VACUITY, both halves. An empty record would make the coverage + // assertion pass over an allowlist that was never asked a question; a + // record that already carried `writable` would make the stamp + // measurement below read a field this door never computed. + const registry = realRegistry(); + const keys = recordKeysOf(registry, CODE_PROJECT); + expect(keys.size).toBeGreaterThan(3); + expect(keys.has('manifest')).toBe(true); + expect(keys.has('status')).toBe(true); + for (const stamp of DOOR_COMPUTED_STAMPS) expect(keys.has(stamp)).toBe(false); + }); + + it('every record key survives to the wire, on the list door and the detail door', async () => { + // THE GATE, half 1. Add a field to the installed-package record without + // adding it to `INSTALLED_PACKAGE_RESPONSE_FIELDS` and this reds with + // the key's own name, instead of shipping a 200 with the field absent. + const registry = realRegistry(); + const rows = await listRows(registry); + const report: string[] = []; + + for (const id of [CODE_PROJECT, SYSTEM_SCOPED, DB_BASE]) { + const record = recordKeysOf(registry, id); + + const listed = rows.find((p) => rowId(p) === id); + expect(listed, `package ${id} vanished from GET /packages`).toBeDefined(); + const listDropped = droppedKeys(record, new Set(Object.keys(listed as object)), DELIBERATELY_NOT_SERVED); + if (listDropped.length) report.push(`list ${id}: ${listDropped.join(', ')}`); + + const detail = await detailRow(registry, id); + const detailDropped = droppedKeys(record, new Set(Object.keys(detail)), DELIBERATELY_NOT_SERVED); + if (detailDropped.length) report.push(`detail ${id}: ${detailDropped.join(', ')}`); + } + + expect( + report, + 'runtime /packages dropped declared record key(s). Either add them to ' + + '`INSTALLED_PACKAGE_RESPONSE_FIELDS` in packages.ts, or record the ' + + 'withholding in `DELIBERATELY_NOT_SERVED` in this file with the reason.', + ).toEqual([]); + }); + + it('the keys this door stamps after the projection are exactly the recorded set', async () => { + // THE GATE, half 2 — the ORDER invariant, generalised past `writable`. + // + // The stamp set is MEASURED (`served − record`), never listed, and then + // compared for SET EQUALITY against the annotated register. Reorder the + // door to stamp before it projects and the projection deletes the stamp: + // the measured set goes empty and this reds, where the wire would have + // shown only a 200 with the field absent. Add a new stamp and this reds + // until the decision is written down. + const registry = realRegistry(); + const rows = await listRows(registry); + const expectedStamps = [...DOOR_COMPUTED_STAMPS].sort(); + + for (const id of [CODE_PROJECT, SYSTEM_SCOPED, DB_BASE]) { + const record = recordKeysOf(registry, id); + + const listed = rows.find((p) => rowId(p) === id) as Row; + const listStamps = Object.keys(listed).filter((k) => !record.has(k)).sort(); + expect(listStamps, `GET /packages stamp set for ${id}`).toEqual(expectedStamps); + + const detail = await detailRow(registry, id); + const detailStamps = Object.keys(detail).filter((k) => !record.has(k)).sort(); + expect(detailStamps, `GET /packages/${id} stamp set`).toEqual(expectedStamps); + } + }); + + it('control: the detector reports a dropped key rather than passing vacuously', async () => { + // Proves the coverage assertion can FAIL, without mutating source. The + // door is real and so is the drop: an undeclared key on the record is + // exactly what the allowlist deletes, and the detector must say so. + const registry = realRegistry(); + (registry.getPackage(CODE_PROJECT) as Record).nextDeclaredField = 'v1'; + + const rows = await listRows(registry); + const listed = rows.find((p) => rowId(p) === CODE_PROJECT) as Row; + const served = new Set(Object.keys(listed)); + + expect(droppedKeys(recordKeysOf(registry, CODE_PROJECT), served, DELIBERATELY_NOT_SERVED)) + .toEqual(['nextDeclaredField']); + // …and the exclusion register is the one way to make that green again. + expect(droppedKeys(recordKeysOf(registry, CODE_PROJECT), served, ['nextDeclaredField'])) + .toEqual([]); + }); + + it('the projection still drops an undeclared LIVE member — the gate does not undo it', async () => { + // The coverage assertion is one-directional (⊇), on purpose: this door + // must keep degrading an unserializable member to a missing field. A + // gate written as set EQUALITY over the record would have forced that + // member back onto the wire and re-opened the 500. + const registry = realRegistry(); + (registry.getPackage(CODE_PROJECT) as Record).liveEngineHandle = cyclicEngine(); + + const rows = await listRows(registry); + expect(() => JSON.stringify(rows)).not.toThrow(); + expect(rows.some((p) => 'liveEngineHandle' in p)).toBe(false); + }); +}); From 5e639a7c1da721cf052cc0f3e9e8872c5b11c59d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 3 Sep 2026 03:16:09 +0000 Subject: [PATCH 2/4] test(packages): measure key sets by DEFINED value, not by property presence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `SchemaRegistry.installPackage` seats the optional record fields as own properties holding `undefined`, so `'settings' in record` is true for a package installed without settings. Both doors omit undefined-valued fields deliberately and JSON.stringify drops them anyway, so counting them made the gate red on every package for a key no consumer could have observed — measured on the first run, six false drops. Also drops the fixture's `findOne` double: `getMetaItems` never reaches that verb, and `check:engine-double-contract` is right that a fake looser than ObjectQL.findOne is worth refusing rather than stubbing. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza --- .../package-door-producer-key-carry.test.ts | 38 ++++++++++++++++--- .../package-door-producer-key-carry.test.ts | 32 +++++++++++++--- 2 files changed, 58 insertions(+), 12 deletions(-) diff --git a/packages/rest/src/package-door-producer-key-carry.test.ts b/packages/rest/src/package-door-producer-key-carry.test.ts index 97e01c67c0..4a727dbf69 100644 --- a/packages/rest/src/package-door-producer-key-carry.test.ts +++ b/packages/rest/src/package-door-producer-key-carry.test.ts @@ -177,14 +177,40 @@ function realProducer(registry: SchemaRegistry): ObjectStackProtocolImplementati ]), // No `sys_metadata` overlay in this fixture: the subject is the registry // half's key set, and an overlay row would only add rows, not keys. + // + // `find` is the ONLY engine verb `getMetaItems` reaches (the rest of the + // read goes through `engine.registry`, which is real here). Deliberately no + // `findOne` double: it belongs to the single-item read this file never + // drives, and a fake looser than `ObjectQL.findOne` is how a dead REST route + // once shipped with its suite green — so the honest fixture omits the verb + // rather than stubbing it. `check:engine-double-contract` enforces that. find: async () => [], - findOne: async () => null, }; return new ObjectStackProtocolImplementation(engine as never, () => new Map()); } type ProducerRow = Record & { manifest?: { id?: unknown } }; +/** + * The keys of `row` that a RESPONSE can actually carry. + * + * `Object.keys` alone is the wrong instrument here and the difference is + * measured, not theoretical: `SchemaRegistry.installPackage` seats the optional + * record fields as own properties holding `undefined`, so a package installed + * without settings still answers `'settings' in record === true`. Both doors + * omit undefined-valued fields on purpose ("the bytes are unchanged for every + * entry that already served fine"), and `JSON.stringify` would drop them + * anyway — so a gate counting them would red on every package for a key no + * consumer could ever have observed, and the first repair anyone reached for + * would be to widen the exclusion register until the real signal was buried. + * + * What this gate is about is a key that HAS a value and does not reach the + * wire. That is the near-miss (`writable: false` is a value), and it is what + * this filter keeps in view. + */ +const definedKeys = (row: Record): Set => + new Set(Object.keys(row).filter((k) => row[k] !== undefined)); + /** The row id, keyed the way both the producer and the door key it. */ function rowId(row: ProducerRow): string | undefined { const fromManifest = row?.manifest?.id; @@ -200,7 +226,7 @@ async function producerKeysOf( const out = new Map>(); for (const item of (res.items ?? []) as ProducerRow[]) { const id = rowId(item); - if (id) out.set(id, new Set(Object.keys(item))); + if (id) out.set(id, definedKeys(item)); } return out; } @@ -285,7 +311,7 @@ describe('GATE: REST GET /packages carries every key the producer stamps', () => expect([...perRow.keys()].sort()).toEqual([DB_BASE, CODE_PROJECT, SYSTEM_SCOPED].sort()); - const recordKeys = new Set(Object.keys(registry.getPackage(CODE_PROJECT) as object)); + const recordKeys = definedKeys(registry.getPackage(CODE_PROJECT) as unknown as Record); const stamped = [...perRow.get(CODE_PROJECT)!].filter((k) => !recordKeys.has(k)); expect(stamped).toContain('writable'); }); @@ -309,7 +335,7 @@ describe('GATE: REST GET /packages carries every key the producer stamps', () => for (const [id, producerKeys] of perRow) { const row = served.find((p) => rowId(p) === id); expect(row, `package ${id} vanished from the response entirely`).toBeDefined(); - const dropped = droppedKeys(producerKeys, new Set(Object.keys(row as object)), DELIBERATELY_NOT_SERVED); + const dropped = droppedKeys(producerKeys, definedKeys(row as ProducerRow), DELIBERATELY_NOT_SERVED); if (dropped.length) report.push(`list ${id}: ${dropped.join(', ')}`); // The DETAIL door runs the same producer through the same projection, and @@ -318,7 +344,7 @@ describe('GATE: REST GET /packages carries every key the producer stamps', () => expect(one.status, `GET ${PKGS}/${id}`).toBe(200); const detail = one.body?.data?.package as ProducerRow | undefined; expect(detail, `package ${id} vanished from the detail response`).toBeDefined(); - const detailDropped = droppedKeys(producerKeys, new Set(Object.keys(detail as object)), DELIBERATELY_NOT_SERVED); + const detailDropped = droppedKeys(producerKeys, definedKeys(detail as ProducerRow), DELIBERATELY_NOT_SERVED); if (detailDropped.length) report.push(`detail ${id}: ${detailDropped.join(', ')}`); } @@ -343,7 +369,7 @@ describe('GATE: REST GET /packages carries every key the producer stamps', () => const res = await drive(mount(protocol), 'GET', PKGS); const row = (res.body?.data?.packages ?? []).find((p: ProducerRow) => rowId(p) === CODE_PROJECT); - const servedKeys = new Set(Object.keys(row as object)); + const servedKeys = definedKeys(row as ProducerRow); // A hypothetical next verdict, stamped by the producer and not yet decided // about at this door. diff --git a/packages/runtime/src/domains/package-door-producer-key-carry.test.ts b/packages/runtime/src/domains/package-door-producer-key-carry.test.ts index 5743fe6563..f65f18a0b6 100644 --- a/packages/runtime/src/domains/package-door-producer-key-carry.test.ts +++ b/packages/runtime/src/domains/package-door-producer-key-carry.test.ts @@ -194,6 +194,26 @@ function dispatcherOver(registry: SchemaRegistry): HttpDispatcher { type Row = Record & { manifest?: { id?: unknown } }; +/** + * The keys of `row` that a RESPONSE can actually carry. + * + * `Object.keys` alone is the wrong instrument here and the difference is + * measured, not theoretical: `SchemaRegistry.installPackage` seats the optional + * record fields as own properties holding `undefined`, so a package installed + * without settings still answers `'settings' in record === true`. This door + * omits undefined-valued fields on purpose ("the response bytes are unchanged + * for every record that was already fine"), and `JSON.stringify` would drop + * them anyway — so a gate counting them would red on every package for a key no + * consumer could ever have observed, and the first repair anyone reached for + * would be to widen the exclusion register until the real signal was buried. + * + * What this gate is about is a key that HAS a value and does not reach the + * wire. That is the near-miss (`writable: false` is a value), and it is what + * this filter keeps in view. + */ +const definedKeys = (row: Record): Set => + new Set(Object.keys(row).filter((k) => row[k] !== undefined)); + const rowId = (row: Row): string | undefined => { const fromManifest = row?.manifest?.id; if (typeof fromManifest === 'string') return fromManifest; @@ -202,7 +222,7 @@ const rowId = (row: Row): string | undefined => { /** MEASURE the record's key set from the real registry — never a literal. */ const recordKeysOf = (registry: SchemaRegistry, id: string): Set => - new Set(Object.keys(registry.getPackage(id) as object)); + definedKeys(registry.getPackage(id) as unknown as Record); /** * THE DETECTOR. Shared in shape with the REST twin: the keys a door drops are @@ -259,11 +279,11 @@ describe('GATE: runtime /packages carries every declared and stamped key', () => const listed = rows.find((p) => rowId(p) === id); expect(listed, `package ${id} vanished from GET /packages`).toBeDefined(); - const listDropped = droppedKeys(record, new Set(Object.keys(listed as object)), DELIBERATELY_NOT_SERVED); + const listDropped = droppedKeys(record, definedKeys(listed as Row), DELIBERATELY_NOT_SERVED); if (listDropped.length) report.push(`list ${id}: ${listDropped.join(', ')}`); const detail = await detailRow(registry, id); - const detailDropped = droppedKeys(record, new Set(Object.keys(detail)), DELIBERATELY_NOT_SERVED); + const detailDropped = droppedKeys(record, definedKeys(detail), DELIBERATELY_NOT_SERVED); if (detailDropped.length) report.push(`detail ${id}: ${detailDropped.join(', ')}`); } @@ -292,11 +312,11 @@ describe('GATE: runtime /packages carries every declared and stamped key', () => const record = recordKeysOf(registry, id); const listed = rows.find((p) => rowId(p) === id) as Row; - const listStamps = Object.keys(listed).filter((k) => !record.has(k)).sort(); + const listStamps = [...definedKeys(listed)].filter((k) => !record.has(k)).sort(); expect(listStamps, `GET /packages stamp set for ${id}`).toEqual(expectedStamps); const detail = await detailRow(registry, id); - const detailStamps = Object.keys(detail).filter((k) => !record.has(k)).sort(); + const detailStamps = [...definedKeys(detail)].filter((k) => !record.has(k)).sort(); expect(detailStamps, `GET /packages/${id} stamp set`).toEqual(expectedStamps); } }); @@ -310,7 +330,7 @@ describe('GATE: runtime /packages carries every declared and stamped key', () => const rows = await listRows(registry); const listed = rows.find((p) => rowId(p) === CODE_PROJECT) as Row; - const served = new Set(Object.keys(listed)); + const served = definedKeys(listed); expect(droppedKeys(recordKeysOf(registry, CODE_PROJECT), served, DELIBERATELY_NOT_SERVED)) .toEqual(['nextDeclaredField']); From b0ea243e26b72289e971d391a599815afc6a0fe0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 3 Sep 2026 03:20:54 +0000 Subject: [PATCH 3/4] test(packages): seat every declared record field, so the gate can see all of them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ablation caught this and it is the more useful of the two findings: deleting `installedVersion` from EITHER door's allowlist left both gates GREEN. `installPackage` leaves 8 of the 12 declared fields as own properties holding `undefined`, and a key the wire cannot carry is correctly invisible to the detector — so the coverage assertion was exercising 4 fields while reading as if it covered the record. `seatDeclaredFields` fills every own key whose value is `undefined`, derived from the record's own key set rather than from a list of field names, so a field added tomorrow is seated without an edit. A control assertion now fails if any declared slot is unobservable again. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza --- .../package-door-producer-key-carry.test.ts | 42 ++++++++++++++++++- .../package-door-producer-key-carry.test.ts | 42 ++++++++++++++++++- 2 files changed, 82 insertions(+), 2 deletions(-) diff --git a/packages/rest/src/package-door-producer-key-carry.test.ts b/packages/rest/src/package-door-producer-key-carry.test.ts index 4a727dbf69..9cd2d6b44d 100644 --- a/packages/rest/src/package-door-producer-key-carry.test.ts +++ b/packages/rest/src/package-door-producer-key-carry.test.ts @@ -124,6 +124,31 @@ const SYSTEM_SCOPED = 'com.objectstack.setup'; /** Studio-created database base: installed, never booted, scope-less. */ const DB_BASE = 'com.acme.mybase'; + +/** + * Seat a value on every DECLARED-BUT-UNSET field of a record. + * + * `installPackage` leaves the optional lifecycle fields as own properties + * holding `undefined`, and {@link definedKeys} — correctly — cannot see a key + * the wire could never carry. The consequence was MEASURED, and it is the + * reason this helper exists: over a bare install the coverage assertion below + * observed only 4 of the record's 12 declared fields, and deleting + * `installedVersion` from the door's allowlist left this gate GREEN. A gate + * blind to two thirds of the surface it names is the defect it was written to + * catch, one level up. + * + * The fill is DERIVED from the record's own key set — every own key whose value + * is `undefined` gets one — so this is still not a hand-written list of field + * names, and a field added to the record tomorrow is seated without an edit + * here. The values are deliberately meaningless: this gate compares KEY SETS, + * and what each field must CONTAIN is pinned by that field's own tests. + */ +function seatDeclaredFields(record: Record): void { + for (const k of Object.keys(record)) { + if (record[k] === undefined) record[k] = `__seated__${k}`; + } +} + /** * A registry in the showcase's shape, built through the REAL * `SchemaRegistry.installPackage` — so the records under test are the records @@ -159,6 +184,12 @@ function realRegistry(): SchemaRegistry { id: DB_BASE, name: 'My Base', namespace: 'mybase', version: '1.0.0', type: 'app', } as never); + // Every declared field carries a value from here on — see + // `seatDeclaredFields` for the measurement that made this necessary. + for (const id of [CODE_PROJECT, SYSTEM_SCOPED, DB_BASE]) { + seatDeclaredFields(registry.getPackage(id) as unknown as Record); + } + return registry; } @@ -311,7 +342,16 @@ describe('GATE: REST GET /packages carries every key the producer stamps', () => expect([...perRow.keys()].sort()).toEqual([DB_BASE, CODE_PROJECT, SYSTEM_SCOPED].sort()); - const recordKeys = definedKeys(registry.getPackage(CODE_PROJECT) as unknown as Record); + // Every declared slot on the record must be OBSERVABLE, or the coverage + // assertion silently shrinks to whatever the fixture happened to populate. + // Measured before `seatDeclaredFields` existed: 8 of 12 declared fields sat + // at `undefined`, and deleting one of them from the allowlist left this + // gate green. Stated as a property of the record — never as a count. + const record = registry.getPackage(CODE_PROJECT) as unknown as Record; + const unobservable = Object.keys(record).filter((k) => record[k] === undefined); + expect(unobservable, 'declared fields the fixture leaves unobservable').toEqual([]); + + const recordKeys = definedKeys(record); const stamped = [...perRow.get(CODE_PROJECT)!].filter((k) => !recordKeys.has(k)); expect(stamped).toContain('writable'); }); diff --git a/packages/runtime/src/domains/package-door-producer-key-carry.test.ts b/packages/runtime/src/domains/package-door-producer-key-carry.test.ts index f65f18a0b6..fd5c97917e 100644 --- a/packages/runtime/src/domains/package-door-producer-key-carry.test.ts +++ b/packages/runtime/src/domains/package-door-producer-key-carry.test.ts @@ -132,6 +132,31 @@ const SYSTEM_SCOPED = 'com.objectstack.setup'; /** Studio-created database base: installed, never booted, scope-less. */ const DB_BASE = 'com.acme.mybase'; + +/** + * Seat a value on every DECLARED-BUT-UNSET field of a record. + * + * `installPackage` leaves the optional lifecycle fields as own properties + * holding `undefined`, and {@link definedKeys} — correctly — cannot see a key + * the wire could never carry. The consequence was MEASURED, and it is the + * reason this helper exists: over a bare install the coverage assertion below + * observed only 4 of the record's 12 declared fields, and deleting + * `installedVersion` from the door's allowlist left this gate GREEN. A gate + * blind to two thirds of the surface it names is the defect it was written to + * catch, one level up. + * + * The fill is DERIVED from the record's own key set — every own key whose value + * is `undefined` gets one — so this is still not a hand-written list of field + * names, and a field added to the record tomorrow is seated without an edit + * here. The values are deliberately meaningless: this gate compares KEY SETS, + * and what each field must CONTAIN is pinned by that field's own tests. + */ +function seatDeclaredFields(record: Record): void { + for (const k of Object.keys(record)) { + if (record[k] === undefined) record[k] = `__seated__${k}`; + } +} + /** * A registry in the showcase's shape, built through the REAL * `SchemaRegistry.installPackage` — so the records under test are the records @@ -169,6 +194,12 @@ function realRegistry(): SchemaRegistry { id: DB_BASE, name: 'My Base', namespace: 'mybase', version: '1.0.0', type: 'app', } as never); + // Every declared field carries a value from here on — see + // `seatDeclaredFields` for the measurement that made this necessary. + for (const id of [CODE_PROJECT, SYSTEM_SCOPED, DB_BASE]) { + seatDeclaredFields(registry.getPackage(id) as unknown as Record); + } + return registry; } @@ -259,8 +290,17 @@ describe('GATE: runtime /packages carries every declared and stamped key', () => // record that already carried `writable` would make the stamp // measurement below read a field this door never computed. const registry = realRegistry(); + // Every declared slot on the record must be OBSERVABLE, or the coverage + // assertion silently shrinks to whatever the fixture happened to + // populate. Measured before `seatDeclaredFields` existed: 8 of 12 + // declared fields sat at `undefined`, and deleting one of them from the + // allowlist left this gate green. Stated as a property of the record — + // never as a count. + const record = registry.getPackage(CODE_PROJECT) as unknown as Record; + const unobservable = Object.keys(record).filter((k) => record[k] === undefined); + expect(unobservable, 'declared fields the fixture leaves unobservable').toEqual([]); + const keys = recordKeysOf(registry, CODE_PROJECT); - expect(keys.size).toBeGreaterThan(3); expect(keys.has('manifest')).toBe(true); expect(keys.has('status')).toBe(true); for (const stamp of DOOR_COMPUTED_STAMPS) expect(keys.has(stamp)).toBe(false); From 5d78b433fd53b25a630980f7e7ed70295596e63a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 3 Sep 2026 03:24:51 +0000 Subject: [PATCH 4/4] test(packages): seat the DECLARED key set, read from InstalledPackageSchema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first ablation's most useful reading: deleting `installedVersion` from either allowlist left both gates GREEN. Six of the twelve declared record fields are ABSENT from a freshly installed record (installPackage writes only what an install can know), so a gate watching only the producer's live output cannot see them dropped. The card asks for `served ⊇ the producer's stamped/DECLARED key set`, so the declared half is derived from the record schema — in the TEST's expectation, never in the production allowlist, which stays hand-written and untouched. That distinction is the one the originating card ruled on and it is spelled out at DECLARED_RECORD_KEYS. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza --- .../package-door-producer-key-carry.test.ts | 69 ++++++++++++------ .../package-door-producer-key-carry.test.ts | 70 +++++++++++++------ 2 files changed, 96 insertions(+), 43 deletions(-) diff --git a/packages/rest/src/package-door-producer-key-carry.test.ts b/packages/rest/src/package-door-producer-key-carry.test.ts index 9cd2d6b44d..4a5565ff98 100644 --- a/packages/rest/src/package-door-producer-key-carry.test.ts +++ b/packages/rest/src/package-door-producer-key-carry.test.ts @@ -81,6 +81,7 @@ import { describe, it, expect } from 'vitest'; import { SchemaRegistry } from '@objectstack/objectql'; import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; import type { RouteHandler } from '@objectstack/spec/contracts'; +import { InstalledPackageSchema } from '@objectstack/spec/kernel'; import { registerPackageRoutes } from './package-routes.js'; const PKGS = '/api/v1/packages'; @@ -126,25 +127,45 @@ const DB_BASE = 'com.acme.mybase'; /** - * Seat a value on every DECLARED-BUT-UNSET field of a record. + * The keys the installed-package RECORD declares, read from the record schema + * ITSELF rather than typed out here. * - * `installPackage` leaves the optional lifecycle fields as own properties - * holding `undefined`, and {@link definedKeys} — correctly — cannot see a key - * the wire could never carry. The consequence was MEASURED, and it is the - * reason this helper exists: over a bare install the coverage assertion below - * observed only 4 of the record's 12 declared fields, and deleting - * `installedVersion` from the door's allowlist left this gate GREEN. A gate - * blind to two thirds of the surface it names is the defect it was written to - * catch, one level up. + * ⚠️ Read this before mistaking it for the design the originating card + * rejected. What was weighed and rejected there is deriving the **production + * allowlist** from `packages/spec` — that would publish a newly declared field + * automatically, making the served surface a side effect of a schema edit. The + * allowlist stays hand-written and is untouched by this file. What is derived + * here is the TEST's EXPECTATION, which is the card's own wording for the + * detector: "the projected key set ⊇ the producer's stamped/**declared** key + * set minus an explicit, annotated exclusion list". Deriving the expectation is + * what turns a newly declared field into a red test — an explicit decision at + * the door — instead of a silent omission. It adds no import edge either: + * `@objectstack/spec` is already a runtime dependency of this package. * - * The fill is DERIVED from the record's own key set — every own key whose value - * is `undefined` gets one — so this is still not a hand-written list of field - * names, and a field added to the record tomorrow is seated without an edit - * here. The values are deliberately meaningless: this gate compares KEY SETS, - * and what each field must CONTAIN is pinned by that field's own tests. + * Measured, and the reason this exists at all: `installPackage` writes only the + * fields an install can know (`manifest`, `status`, `enabled`, `installedAt`, + * `updatedAt`, `settings`). The five lifecycle fields the record also declares + * — `installedVersion`, `previousVersion`, `statusChangedAt`, `errorMessage`, + * `upgradeHistory`, `registeredNamespaces` — are simply ABSENT from a freshly + * installed record, so a gate that watched only the producer's live output + * could not see them dropped: deleting `installedVersion` from the allowlist + * left this gate GREEN on its first ablation. + */ +const DECLARED_RECORD_KEYS: readonly string[] = Object.keys( + (InstalledPackageSchema as unknown as { shape: Record }).shape, +); + +/** + * Give every DECLARED field a value on this record, so the door can be observed + * either carrying it or dropping it. + * + * Without this the coverage assertion silently shrinks to the handful of fields + * a fresh install happens to write, while reading as though it covered the + * record. The values are deliberately meaningless — this gate compares KEY + * SETS, and what each field must CONTAIN is pinned by that field's own tests. */ function seatDeclaredFields(record: Record): void { - for (const k of Object.keys(record)) { + for (const k of DECLARED_RECORD_KEYS) { if (record[k] === undefined) record[k] = `__seated__${k}`; } } @@ -342,13 +363,19 @@ describe('GATE: REST GET /packages carries every key the producer stamps', () => expect([...perRow.keys()].sort()).toEqual([DB_BASE, CODE_PROJECT, SYSTEM_SCOPED].sort()); - // Every declared slot on the record must be OBSERVABLE, or the coverage - // assertion silently shrinks to whatever the fixture happened to populate. - // Measured before `seatDeclaredFields` existed: 8 of 12 declared fields sat - // at `undefined`, and deleting one of them from the allowlist left this - // gate green. Stated as a property of the record — never as a count. + // The schema read must have WORKED. `InstalledPackageSchema` is a lazy + // proxy; a read that silently produced `{}` would make DECLARED_RECORD_KEYS + // empty and every assertion below vacuous, in a suite that stayed green. + expect(DECLARED_RECORD_KEYS.length).toBeGreaterThanOrEqual(8); + expect(DECLARED_RECORD_KEYS).toContain('installedVersion'); + + // …and every declared slot must be OBSERVABLE on the record, or the + // coverage assertion silently shrinks to whatever a fresh install wrote. + // Measured before `seatDeclaredFields` read the schema: 6 of the 12 + // declared fields were absent from the record, and deleting one of them + // from the allowlist left this gate green. const record = registry.getPackage(CODE_PROJECT) as unknown as Record; - const unobservable = Object.keys(record).filter((k) => record[k] === undefined); + const unobservable = DECLARED_RECORD_KEYS.filter((k) => record[k] === undefined); expect(unobservable, 'declared fields the fixture leaves unobservable').toEqual([]); const recordKeys = definedKeys(record); diff --git a/packages/runtime/src/domains/package-door-producer-key-carry.test.ts b/packages/runtime/src/domains/package-door-producer-key-carry.test.ts index fd5c97917e..44cf078982 100644 --- a/packages/runtime/src/domains/package-door-producer-key-carry.test.ts +++ b/packages/runtime/src/domains/package-door-producer-key-carry.test.ts @@ -72,6 +72,7 @@ import { describe, it, expect } from 'vitest'; import { SchemaRegistry } from '@objectstack/objectql'; +import { InstalledPackageSchema } from '@objectstack/spec/kernel'; import { HttpDispatcher } from '../http-dispatcher.js'; /** @@ -134,25 +135,45 @@ const DB_BASE = 'com.acme.mybase'; /** - * Seat a value on every DECLARED-BUT-UNSET field of a record. + * The keys the installed-package RECORD declares, read from the record schema + * ITSELF rather than typed out here. * - * `installPackage` leaves the optional lifecycle fields as own properties - * holding `undefined`, and {@link definedKeys} — correctly — cannot see a key - * the wire could never carry. The consequence was MEASURED, and it is the - * reason this helper exists: over a bare install the coverage assertion below - * observed only 4 of the record's 12 declared fields, and deleting - * `installedVersion` from the door's allowlist left this gate GREEN. A gate - * blind to two thirds of the surface it names is the defect it was written to - * catch, one level up. + * ⚠️ Read this before mistaking it for the design the originating card + * rejected. What was weighed and rejected there is deriving the **production + * allowlist** from `packages/spec` — that would publish a newly declared field + * automatically, making the served surface a side effect of a schema edit. The + * allowlist stays hand-written and is untouched by this file. What is derived + * here is the TEST's EXPECTATION, which is the card's own wording for the + * detector: "the projected key set ⊇ the producer's stamped/**declared** key + * set minus an explicit, annotated exclusion list". Deriving the expectation is + * what turns a newly declared field into a red test — an explicit decision at + * the door — instead of a silent omission. It adds no import edge either: + * `@objectstack/spec` is already a runtime dependency of this package. * - * The fill is DERIVED from the record's own key set — every own key whose value - * is `undefined` gets one — so this is still not a hand-written list of field - * names, and a field added to the record tomorrow is seated without an edit - * here. The values are deliberately meaningless: this gate compares KEY SETS, - * and what each field must CONTAIN is pinned by that field's own tests. + * Measured, and the reason this exists at all: `installPackage` writes only the + * fields an install can know (`manifest`, `status`, `enabled`, `installedAt`, + * `updatedAt`, `settings`). The five lifecycle fields the record also declares + * — `installedVersion`, `previousVersion`, `statusChangedAt`, `errorMessage`, + * `upgradeHistory`, `registeredNamespaces` — are simply ABSENT from a freshly + * installed record, so a gate that watched only the producer's live output + * could not see them dropped: deleting `installedVersion` from the allowlist + * left this gate GREEN on its first ablation. + */ +const DECLARED_RECORD_KEYS: readonly string[] = Object.keys( + (InstalledPackageSchema as unknown as { shape: Record }).shape, +); + +/** + * Give every DECLARED field a value on this record, so the door can be observed + * either carrying it or dropping it. + * + * Without this the coverage assertion silently shrinks to the handful of fields + * a fresh install happens to write, while reading as though it covered the + * record. The values are deliberately meaningless — this gate compares KEY + * SETS, and what each field must CONTAIN is pinned by that field's own tests. */ function seatDeclaredFields(record: Record): void { - for (const k of Object.keys(record)) { + for (const k of DECLARED_RECORD_KEYS) { if (record[k] === undefined) record[k] = `__seated__${k}`; } } @@ -290,14 +311,19 @@ describe('GATE: runtime /packages carries every declared and stamped key', () => // record that already carried `writable` would make the stamp // measurement below read a field this door never computed. const registry = realRegistry(); - // Every declared slot on the record must be OBSERVABLE, or the coverage - // assertion silently shrinks to whatever the fixture happened to - // populate. Measured before `seatDeclaredFields` existed: 8 of 12 - // declared fields sat at `undefined`, and deleting one of them from the - // allowlist left this gate green. Stated as a property of the record — - // never as a count. + // The schema read must have WORKED. `InstalledPackageSchema` is a lazy + // proxy; a read that silently produced `{}` would make DECLARED_RECORD_KEYS + // empty and every assertion below vacuous, in a suite that stayed green. + expect(DECLARED_RECORD_KEYS.length).toBeGreaterThanOrEqual(8); + expect(DECLARED_RECORD_KEYS).toContain('installedVersion'); + + // …and every declared slot must be OBSERVABLE on the record, or the + // coverage assertion silently shrinks to whatever a fresh install wrote. + // Measured before `seatDeclaredFields` read the schema: 6 of the 12 + // declared fields were absent from the record, and deleting one of them + // from the allowlist left this gate green. const record = registry.getPackage(CODE_PROJECT) as unknown as Record; - const unobservable = Object.keys(record).filter((k) => record[k] === undefined); + const unobservable = DECLARED_RECORD_KEYS.filter((k) => record[k] === undefined); expect(unobservable, 'declared fields the fixture leaves unobservable').toEqual([]); const keys = recordKeysOf(registry, CODE_PROJECT);