From fef3c7a338bf370194a580de960f967626c44b4e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 08:50:51 +0000 Subject: [PATCH 1/6] wip(rest): thread org partition into /meta history + diff read doors --- ...server-meta-history-diff-org-scope.test.ts | 482 ++++++++++++++++++ packages/rest/src/rest-server.ts | 85 +++ 2 files changed, 567 insertions(+) create mode 100644 packages/rest/src/rest-server-meta-history-diff-org-scope.test.ts diff --git a/packages/rest/src/rest-server-meta-history-diff-org-scope.test.ts b/packages/rest/src/rest-server-meta-history-diff-org-scope.test.ts new file mode 100644 index 0000000000..6428fcd100 --- /dev/null +++ b/packages/rest/src/rest-server-meta-history-diff-org-scope.test.ts @@ -0,0 +1,482 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// #13406 — `GET /meta/:type/:name/history` and `GET /meta/:type/:name/diff` +// named no organization, so both read the ENV partition of a per-org table. An +// item whose overlay was authored org-scoped answered `{ events: [] }` and an +// all-empty diff while `sys_metadata_history` held its full log. Direction is +// fail-closed — the caller's OWN org data is under-served; there is no +// cross-org read, and the controls at the bottom of this file are what keep it +// that way. +// +// ── Why these two doors and not "the read path" ─────────────────────────── +// +// Every OTHER `/meta` read door already states the scope: the single-item read +// and the listing (#9454), `/layers` (#9454), `/published`, `/_drafts`, and the +// audit twin (#8747). These two were the residue. `protocol.ts` is not at +// fault and is not touched: `request.organizationId ?? null` is the legitimate +// spelling of "env partition", and every correct caller depends on it. +// +// ── ⭐ Why `organizationIdForMetaRead` and NOT the audit twin's expression ── +// +// The audit door passes a RAW `ctx?.tenantId ?? null`, and copying that here +// looks like the obvious repair. It is wrong twice, and both halves are +// asserted below rather than argued: +// +// 1. `auditMetaItem` reads with `$or: [{organization_id: org}, {organization_id: +// null}]` — a UNION, so naming an org there can only add rows. These two +// doors read `sys_metadata_history` with strict equality +// (`SysMetadataRepository.history()` and `diffMetaItem`'s own `find`, both +// `organization_id: orgId`, no `$or`). Under strict equality a raw tenant id +// asks the ORG partition for the history of the types whose rows land +// ENV-WIDE — every `allowOrgOverride: false` type that is still +// runtime-writable, because `organizationIdForMetaWrite` writes those +// env-wide by the #6190 ruling. `object` is the measured specimen, and +// `serves a NON-overridable type's env-wide history to an org session` is +// the assertion that reddens under that ablation. Predicted before running +// it, and it is the whole reason this file exists in this shape. +// 2. `HistoryMetaItemRequestSchema` declares `organizationId: +// z.string().optional()` — optional plain string, NOT nullable, mirroring +// the implementation's `organizationId?: string`. `?? null` on the history +// door is a TS2353 compile error; on the diff door — reached through +// `(p as any)` — it type-checks and is a silent RUNTIME no-op, since +// `null ?? null` is `null`. Hence the omit-spread on both. +// +// ── Why the harness is the REAL protocol, not a spy ─────────────────────── +// +// A spy asserting "the door passed `organizationId`" cannot tell a fix from a +// fix-shaped no-op. The claim is write-then-READ AGREEMENT, so the rows have to +// land in a partition and come back out of it. These drive real REST routes +// against a real `ObjectStackProtocolImplementation` over a stub engine whose +// `sys_metadata_history` table HONOURS the `where` — including +// `organization_id`. That is the load-bearing difference from +// `rest-server-meta-read-org-scope.test.ts`, whose stub returns every history +// row unfiltered: over that engine both doors pass with or without the fix, +// because there is no partition to miss. +// +// ⭐ Every read assertion is preceded by a FIXTURE PROOF that the org-scoped +// history row exists (`historyRowsFor`). "The read is org-scoped" is worthless +// if the fixture never created an org-scoped row, and the card's own repro bar +// was "confirm the pg rows exist before hitting the read door". + +import { describe, it, expect, beforeEach } from 'vitest'; +import { assertEngineDeleteDispatch, assertEngineUpdateDispatch, assertEngineFindOnePredicate } from '@objectstack/metadata-core'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { RestServer } from './rest-server.js'; + +const META = '/api/v1/meta'; +const ORG_A = 'org_alpha'; +const ORG_B = 'org_beta'; + +/** The five types the registry declares `allowOrgOverride: true`. */ +const ORG_OVERRIDABLE = ['view', 'dashboard', 'report', 'translation', 'email_template'] as const; + +/** + * `allowOrgOverride: false` **and** `allowRuntimeCreate: true` — the + * combination that makes this the discriminating control rather than + * decoration. Its writes land ENV-WIDE even for a session with an active org + * (`organizationIdForMetaWrite`), so its history lives in the env partition and + * an org-scoped read of it finds nothing. A type that could not be written at + * runtime at all would have no history either way and would prove nothing. + */ +const NON_OVERRIDABLE = 'object'; + +const MARKER = 'AUTHORED_AT_RUNTIME'; +const MARKER_2 = 'AUTHORED_AT_RUNTIME_REV2'; + +/** + * A SPEC-VALID body per type, carrying `label` as the marker. Real bodies, not + * `{ label }` stubs: the write door runs full spec validation + * (`INVALID_METADATA`, 422), so a thin fixture never reaches the store and + * every assertion below would fail for a reason unrelated to org scoping. + */ +function bodyFor(type: string, name: string, label = MARKER): Record { + const marker = { name, label }; + switch (type) { + case 'view': + return { ...marker, object: 'task', viewKind: 'list', columns: [{ field: 'name', label: 'Name' }] }; + case 'dashboard': + return { ...marker, widgets: [] }; + case 'report': + return { ...marker, dataset: 'orders_ds', values: ['order_count'] }; + case 'translation': + return { ...marker, locale: 'en-US' }; + case 'email_template': + return { ...marker, subject: 'Hi', bodyHtml: '

Hello

' }; + case 'object': + // [ADR-0090 D1] an authored `sharingModel` is required at the write + // door; without it this control fails on the WRITE and never + // reaches the read it exists to make. + return { + ...marker, + sharingModel: 'private', + fields: { title: { type: 'text', label: 'Title' } }, + }; + default: + throw new Error(`no fixture body for type ${type}`); + } +} + +// ── stub engine — `sys_metadata_history` IS PARTITIONED ─────────────────── + +interface Row { + id: string; type: string; name: string; + organization_id: string | null; package_id: string | null; + state: string; metadata: string; checksum?: string; version?: number; +} + +interface HistoryRow { + id: string; type: string; name: string; + organization_id: string | null; + version: number; event_seq: number; + operation_type: string; metadata: string | null; +} + +const keyOf = (w: Record) => + `${w.type}|${w.name}|${w.organization_id ?? '__env__'}|${w.state ?? 'active'}|${w.package_id ?? '__nopkg__'}`; + +function matchesWhere(r: Record, where: Record): boolean { + for (const [k, v] of Object.entries(where)) { + if (k === '$or') { + const clauses = v as Array>; + if (!clauses.some((c) => matchesWhere(r, c))) return false; + continue; + } + if (k === 'context') continue; + if (v === undefined) continue; + if (r[k] !== v) return false; + } + return true; +} + +function makeStubEngine() { + const rows = new Map(); + const historyRows: HistoryRow[] = []; + let nextId = 0; + + const findRow = (w: Record): { key: string; row: Row } | null => { + if (w.id !== undefined) { + for (const [k, r] of rows) if (r.id === w.id) return { key: k, row: r }; + return null; + } + if (w.package_id !== undefined) { + const k = keyOf(w); + const r = rows.get(k); + if (r) return { key: k, row: r }; + } + for (const [k, r] of rows) if (matchesWhere(r as unknown as Record, w)) return { key: k, row: r }; + return null; + }; + + const engine: any = { + async findOne(table: string, opts: { where: Record }) { + assertEngineFindOnePredicate(table, opts); + if (table === 'sys_metadata_history') { + // ⭐ Honours the `where` — `organization_id` included. The + // sibling read-scope harness returns `null` unconditionally + // here, which is why it cannot see this card's defect. + return historyRows.find( + (h) => matchesWhere(h as unknown as Record, opts.where), + ) ?? null; + } + return findRow(opts.where)?.row ?? null; + }, + async find(table: string, opts?: { where?: Record }) { + if (table === 'sys_metadata_history') { + // ⭐ THE POSITIVE CONTROL'S FOUNDATION. `history()` and + // `diffMetaItem` both filter `organization_id` by strict + // equality, so an unfiltered stub answers every read + // identically and no org-scoping assertion here could ever + // fail. Partitioning the stub is what makes the door's + // behaviour observable at all. + return historyRows.filter( + (h) => matchesWhere(h as unknown as Record, opts?.where ?? {}), + ); + } + return Array.from(rows.values()).filter( + (r) => matchesWhere(r as unknown as Record, opts?.where ?? {}), + ); + }, + async insert(table: string, data: Record) { + if (table === 'sys_metadata_audit') return { id: 'audit_skip' }; + if (table === 'sys_metadata_history') { + nextId += 1; + historyRows.push({ ...(data as unknown as HistoryRow), id: `h_${nextId}` }); + return { id: `h_${nextId}` }; + } + if (table !== 'sys_metadata') return { id: 'side_effect_skip' }; + nextId += 1; + const row = { ...(data as unknown as Row), id: `r_${nextId}` }; + rows.set(keyOf(data), row); + return { id: row.id }; + }, + async update(_t: string, data: Record, opts: { where: Record }) { + assertEngineUpdateDispatch(data, opts); + const found = findRow(opts.where); + if (!found) return { id: null }; + const merged = { ...found.row, ...(data as unknown as Row) }; + rows.delete(found.key); + rows.set(keyOf(merged as unknown as Record), merged); + return { id: found.row.id }; + }, + async delete(_t: string, opts: { where: Record }) { + assertEngineDeleteDispatch(opts); + const found = findRow(opts.where); + if (!found) return { deleted: 0 }; + rows.delete(found.key); + return { deleted: 1 }; + }, + async transaction(cb: (ctx: unknown, info: { owned: boolean }) => Promise): Promise { + return cb(undefined, { owned: true }); + }, + async syncObjectSchema() { return true; }, + registry: { + registerItem: () => {}, registerObject: () => {}, + listItems: () => [], getItem: () => undefined, + getObject: () => undefined, getPackage: () => undefined, + getArtifactItem: () => undefined, + isPackageDisabled: () => false, + }, + }; + return { engine, rows, historyRows }; +} + +// ── REST harness: real protocol, real routes, one boot ──────────────────── + +function mockServer() { + const noop = () => {}; + return { + get: noop, post: noop, put: noop, delete: noop, patch: noop, use: noop, + listen: async () => undefined, close: async () => undefined, + }; +} + +function mockRes() { + const res: any = { + statusCode: 200, + _body: undefined, + json(body: any) { this._body = body; return this; }, + send() { return this; }, + setHeader() { return this; }, + status(code: number) { this.statusCode = code; return this; }, + header() { return this; }, + }; + return res; +} + +function boot() { + const { engine, rows, historyRows } = makeStubEngine(); + const protocol = new ObjectStackProtocolImplementation(engine, () => new Map()) as any; + protocol.getDiscovery = async () => ({ + version: 'v0', routes: { data: '', metadata: '', ui: '', auth: '/auth' }, + }); + + const rest = new RestServer( + mockServer() as any, + protocol as any, + { api: { requireAuth: false } } as any, + ); + let session: any = { userId: 'u1', systemPermissions: ['manage_metadata'], tenantId: ORG_A }; + (rest as any).resolveExecCtx = async () => session; + rest.registerRoutes(); + + const drive = async (method: string, path: string, req: Record = {}) => { + const found = (rest as any).getRoutes().find( + (r: any) => r.method === method && r.path === path, + ); + if (!found) throw new Error(`route not registered: ${method} ${path}`); + const res = mockRes(); + let thrown: any; + try { + await found.handler( + { method, path, params: {}, query: {}, headers: {}, body: {}, ...req } as any, + res, + ); + } catch (err) { thrown = err; } + return { status: res.statusCode, body: res._body, thrown }; + }; + + return { + rows, + historyRows, + as(tenantId: string | undefined) { + session = tenantId === undefined + ? { userId: 'u1', systemPermissions: ['manage_metadata'] } + : { userId: 'u1', systemPermissions: ['manage_metadata'], tenantId }; + }, + put: (type: string, name: string, label = MARKER) => + drive('PUT', `${META}/:type/:name`, { params: { type, name }, body: bodyFor(type, name, label) }), + get: (type: string, name: string) => + drive('GET', `${META}/:type/:name`, { params: { type, name } }), + history: (type: string, name: string) => + drive('GET', `${META}/:type/:name/history`, { params: { type, name } }), + diff: (type: string, name: string, query: Record = {}) => + drive('GET', `${META}/:type/:name/diff`, { params: { type, name }, query }), + /** ⭐ The fixture proof every read assertion below is gated on. */ + historyRowsFor: (type: string, name: string, org: string | null) => + historyRows.filter((h) => h.type === type && h.name === name + && (h.organization_id ?? null) === org), + }; +} + +function servedDocument(body: any): any { + if (!body || typeof body !== 'object') return undefined; + return body.item ?? body.data ?? body; +} + +describe('#13406 the /history and /diff read doors state the org partition', () => { + let b: ReturnType; + beforeEach(() => { b = boot(); }); + + describe('⭐ fixture first — the org-scoped rows exist before any door is read', () => { + it.each(ORG_OVERRIDABLE)( + '%s: a PUT under an active org appends an ORG-SCOPED history row, and none env-wide', + async (type) => { + const written = await b.put(type, 'authored_at_runtime'); + expect(written.status, `PUT /${type} was not accepted`).toBe(200); + expect(written.body?.state).toBe('active'); + + const orgRows = b.historyRowsFor(type, 'authored_at_runtime', ORG_A); + const envRows = b.historyRowsFor(type, 'authored_at_runtime', null); + expect( + orgRows.length, + 'nothing landed in the org partition; every read assertion below would ' + + 'then pass or fail for a reason that has nothing to do with org scoping', + ).toBe(1); + // The other half of the premise: the rows are NOT in the env + // partition, which is exactly why an org-blind door missed them. + expect(envRows.length, 'the write also landed env-wide — the partition is not real').toBe(0); + }, + ); + }); + + describe('/history serves the org-scoped change log', () => { + it.each(ORG_OVERRIDABLE)('%s: the events the org authored come back', async (type) => { + await b.put(type, 'authored_at_runtime'); + await b.put(type, 'authored_at_runtime', MARKER_2); + expect(b.historyRowsFor(type, 'authored_at_runtime', ORG_A).length).toBe(2); + + const read = await b.history(type, 'authored_at_runtime'); + expect(read.thrown, `GET /${type}/history threw: ${read.thrown?.message}`).toBeUndefined(); + expect(read.status).toBe(200); + expect( + read.body?.events?.length, + 'the door answered an empty change log for an item whose org partition holds two events', + ).toBe(2); + expect(read.body.events.map((e: any) => e.version)).toEqual([1, 2]); + }); + }); + + describe('/diff resolves org-scoped versions', () => { + it.each(ORG_OVERRIDABLE)('%s: ?from=1&to=2 compares the two org revisions', async (type) => { + await b.put(type, 'two_revisions'); + await b.put(type, 'two_revisions', MARKER_2); + expect(b.historyRowsFor(type, 'two_revisions', ORG_A).map((h) => h.version)).toEqual([1, 2]); + + const read = await b.diff(type, 'two_revisions', { from: '1', to: '2' }); + expect(read.thrown, `GET /${type}/diff threw: ${read.thrown?.message}`).toBeUndefined(); + expect(read.status).toBe(200); + expect(read.body?.fromVersion).toBe(1); + expect(read.body?.toVersion).toBe(2); + // The card's shape: bounds echoed but every bucket empty, because + // neither body could be resolved out of the env partition. + expect( + read.body?.changed, + 'the diff resolved no bodies — the card\'s all-empty answer', + ).toContainEqual({ path: 'label', from: MARKER, to: MARKER_2 }); + }); + }); + + describe('⛔ controls — the scope is STATED, never widened', () => { + it('serves a NON-overridable type\'s env-wide history to an org session', async () => { + // ⭐ THE ABLATION TARGET, and the reason this door uses + // `organizationIdForMetaRead` rather than a raw `ctx?.tenantId`. + // `object` is `allowOrgOverride: false` + `allowRuntimeCreate: true`, + // so `organizationIdForMetaWrite` puts its history ENV-WIDE even + // though ORG_A is active. A door that named the tenant + // unconditionally would query the org partition and answer + // `{ events: [] }` — reintroducing this very card one type family + // over. PREDICTED DIRECTION: swap the predicate for + // `ctx?.tenantId ?? null` and this test, and only this test, turns + // red. + const written = await b.put(NON_OVERRIDABLE, 'accounts'); + expect(written.status, 'the control never wrote').toBe(200); + expect( + b.historyRowsFor(NON_OVERRIDABLE, 'accounts', null).length, + 'a non-overridable write went org-scoped; the control no longer controls anything', + ).toBe(1); + expect(b.historyRowsFor(NON_OVERRIDABLE, 'accounts', ORG_A).length).toBe(0); + + const read = await b.history(NON_OVERRIDABLE, 'accounts'); + expect(read.status).toBe(200); + expect( + read.body?.events?.length, + 'the org session lost sight of an env-wide change log it could read before', + ).toBe(1); + }); + + it('still serves env-scoped rows to an env-scoped caller', async () => { + // The other direction of the same harness: nothing about naming the + // org for org callers may disturb the org-less read that worked all + // along. + b.as(undefined); + await b.put('view', 'env_authored'); + expect(b.historyRowsFor('view', 'env_authored', null).length).toBe(1); + + const read = await b.history('view', 'env_authored'); + expect(read.status).toBe(200); + expect(read.body?.events?.length, 'an env-scoped caller lost its own history').toBe(1); + }); + + it('does not serve org A history to org B on the same boot', async () => { + await b.put('dashboard', 'tenant_bound'); + await b.put('dashboard', 'tenant_bound', MARKER_2); + expect(b.historyRowsFor('dashboard', 'tenant_bound', ORG_A).length).toBe(2); + + b.as(ORG_B); + const read = await b.history('dashboard', 'tenant_bound'); + expect(read.status).toBe(200); + expect(read.body?.events ?? [], 'org B was served org A\'s change log').toEqual([]); + + const diffed = await b.diff('dashboard', 'tenant_bound', { from: '1', to: '2' }); + expect(diffed.status).toBe(200); + expect(diffed.body?.changed ?? [], 'org B was served a diff of org A\'s revisions').toEqual([]); + }); + + it('does not serve an org row to a caller that named no org', async () => { + await b.put('view', 'org_a_only'); + expect(b.historyRowsFor('view', 'org_a_only', ORG_A).length).toBe(1); + + b.as(undefined); + const read = await b.history('view', 'org_a_only'); + expect(read.status).toBe(200); + expect( + read.body?.events ?? [], + 'an org-less caller was served an org-scoped change log', + ).toEqual([]); + }); + }); + + describe('the card\'s third symptom, RE-MEASURED on today\'s main', () => { + it('single-item dashboard read ALREADY serves the org overlay — premise falsified', async () => { + // #13406 symptom 3 claimed `GET /meta/dashboard/:name` ignores an + // org-scoped overlay. That door was threaded by #9454/#9727 before + // this card was filed; the uncached arm `dashboard` takes carries + // `readOrganizationId` today. Pinned HERE, next to the two doors + // that were genuinely open, so the falsification is auditable + // rather than a claim in a report. (The behaviour itself is owned + // by `rest-server-meta-read-org-scope.test.ts`; this asserts the + // narrow fact the card disputes.) + const written = await b.put('dashboard', 'system_overview'); + expect(written.status).toBe(200); + const row = Array.from(b.rows.values()).find((r) => r.name === 'system_overview'); + expect(row?.organization_id, 'the overlay is not org-scoped; nothing is being measured').toBe(ORG_A); + + const read = await b.get('dashboard', 'system_overview'); + expect(read.status).toBe(200); + expect( + servedDocument(read.body)?.label, + 'the single-item dashboard read did NOT serve the org overlay — symptom 3 is live after all', + ).toBe(MARKER); + }); + }); +}); diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index bc216e780d..afe5b30b15 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -6172,6 +6172,45 @@ export class RestServer { const limit = req.query?.limit !== undefined ? Number(req.query.limit) : undefined; + // [#13406] STATE THE ORG PARTITION. `sys_metadata_history` + // is a per-org log — `SysMetadataRepository.history()` + // filters `organization_id = this.organizationId` by strict + // equality (no `$or`), and `event_seq` is documented as a + // "Per-organization monotonic event log cursor". So a door + // that names no organization does not read "everything": it + // reads the ENV partition (`organizationId ?? null` in + // `historyMetaItem`), and an item whose overlay was authored + // org-scoped answered `{ events: [] }` while its log was + // full. The write door that produced those rows has stated + // the org since #8805; only the read door had not. + // + // ⭐ `organizationIdForMetaRead`, NOT the audit twin's raw + // `ctx?.tenantId ?? null`, and the difference is measured + // rather than stylistic. `auditMetaItem` reads with + // `$or: [{organization_id: org}, {organization_id: null}]`, + // so naming an org there can only ADD rows. This door's + // repository does strict equality, so a raw tenant id would + // ask the org partition for the history of a type whose + // rows land ENV-WIDE — every `allowOrgOverride: false` type + // that is still runtime-writable (`object`, `hook`, `page`, + // `app`, `dataset`), because `organizationIdForMetaWrite` + // deliberately writes those env-wide (#6190). That would + // turn a working read into `{ events: [] }` for them: the + // card's own defect, newly minted one type family over. + // Gating the read on the same registry predicate the WRITE + // uses is what makes the two sides incapable of drifting — + // the reasoning `organizationIdForMetaRead` was written for. + // + // ⚠️ NOT a new org-resolution seam: `resolveExecCtx` is + // memoised per request (WeakMap keyed by `req`), the same + // result the audit twin and 40+ handlers here already share. + const historyCtx = await this.resolveExecCtx(environmentId, req) + .catch(rethrowAuthzStoreUnavailable); + const historyOrganizationId = organizationIdForMetaRead( + // [#10340] FOLDED, not raw — see the PUT door's + // org-scope comment for the measurement. + canonicalMetaUrlType(req.params.type), historyCtx?.tenantId, + ); // Typed through `TransportScopedMetaRequest` like the // reset door above, NOT as a plain `HistoryMetaItemRequest` // like the audit door below: this door still spreads the @@ -6182,10 +6221,21 @@ export class RestServer { // member on. Every OTHER key is compiled against the spec // contract — an undeclared member here is now TS2353 // instead of a payload member no contract has ever seen. + // + // ⛔ [#13406] `organizationId` is SPREAD, never written as + // `organizationId: x ?? null`. `HistoryMetaItemRequestSchema` + // declares it `z.string().optional()` — optional plain + // string, NOT nullable, mirroring the implementation's + // `organizationId?: string` — and the spec's own describe + // text names the asymmetry against the audit twin, which + // declares `string | null`. Copying the audit door's + // expression here is a TS2353 compile error, and would be a + // no-op at runtime anyway (`null ?? null` is `null`). const historyRequest: TransportScopedMetaRequest = { type: req.params.type, name: req.params.name, ...(environmentId ? { environmentId } : {}), + ...(historyOrganizationId ? { organizationId: historyOrganizationId } : {}), ...(sinceSeq !== undefined && Number.isFinite(sinceSeq) ? { sinceSeq } : {}), ...(limit !== undefined && Number.isFinite(limit) ? { limit } : {}), }; @@ -6677,10 +6727,45 @@ export class RestServer { if (refuseRepeatedQueryParams(req, res, ['from', 'fromVersion', 'to', 'toVersion'])) return; const fromVersion = parseV(req.query?.from ?? req.query?.fromVersion); const toVersion = parseV(req.query?.to ?? req.query?.toVersion); + // [#13406] STATE THE ORG PARTITION — the history twin's + // omission, on the door that reads the SAME table. See the + // history door above for why the predicate is + // `organizationIdForMetaRead` and not the audit twin's raw + // `ctx?.tenantId ?? null`; both arguments carry over + // unchanged, because `diffMetaItem` reads + // `sys_metadata_history` with the identical strict-equality + // `where` (`organization_id: orgId`, no `$or`) and derives + // `orgId` from the identical `request.organizationId ?? null`. + // + // Version identity is the second reason the partition is + // strict rather than unioned here, and it is sharper on this + // door than on `/history`: `version` is a PER-(org,type,name) + // lineage counter, so an org revision 1 and an env revision 1 + // both exist. `?from=1&to=2` unioned across partitions would + // have two candidate bodies per bound and would answer a diff + // between revisions of two different lineages — a well-formed + // 200 that is simply not the comparison anyone asked for. + // + // ⚠️ This door reaches `diffMetaItem` through `(p as any)`, + // so — unlike the history twin — the compiler checks NOTHING + // about this literal; measured, not assumed. The omit-spread + // is therefore load-bearing by RUNTIME contract alone: the + // implementation declares `organizationId?: string` and does + // `request.organizationId ?? null`, so an `?? null` copied + // from the audit door would type-check here and still be a + // silent no-op — the exact fix-shaped-non-fix this card is. + const diffCtx = await this.resolveExecCtx(environmentId, req) + .catch(rethrowAuthzStoreUnavailable); + const diffOrganizationId = organizationIdForMetaRead( + // [#10340] FOLDED, not raw — see the PUT door's + // org-scope comment for the measurement. + canonicalMetaUrlType(req.params.type), diffCtx?.tenantId, + ); const result = await (p as any).diffMetaItem({ type: req.params.type, name: req.params.name, ...(environmentId ? { environmentId } : {}), + ...(diffOrganizationId ? { organizationId: diffOrganizationId } : {}), ...(fromVersion !== undefined ? { fromVersion } : {}), ...(toVersion !== undefined ? { toVersion } : {}), }); From 4b5c0d99e570c8ca52152b73214410c7a6a3141d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 09:14:59 +0000 Subject: [PATCH 2/6] fix(rest): /meta history + diff read doors state the org partition (#13406) --- ...a-history-diff-read-doors-org-partition.md | 47 +++++++++++++++++++ .../rest/src/execctx-consumer-census.test.ts | 28 ++++++++--- 2 files changed, 69 insertions(+), 6 deletions(-) create mode 100644 .changeset/meta-history-diff-read-doors-org-partition.md diff --git a/.changeset/meta-history-diff-read-doors-org-partition.md b/.changeset/meta-history-diff-read-doors-org-partition.md new file mode 100644 index 0000000000..f4254a7d82 --- /dev/null +++ b/.changeset/meta-history-diff-read-doors-org-partition.md @@ -0,0 +1,47 @@ +--- +"@objectstack/rest": patch +--- + +fix(rest): `/meta/:type/:name/history` and `/diff` state the org partition they read (#13406) + +`GET /api/v1/meta/:type/:name/history` answered `{ events: [] }`, and +`GET /api/v1/meta/:type/:name/diff` answered an all-empty diff, for metadata +whose overlay was authored **org-scoped** — while `sys_metadata_history` held +the full log. Both doors named no organization, and `sys_metadata_history` is a +**per-org** table: `SysMetadataRepository.history()` and `diffMetaItem` filter +`organization_id` by **strict equality** (no `$or`), so a door that states no +organization does not read "everything" — it reads the **env** partition +(`request.organizationId ?? null`). The write door has stated the org since +#8805; only these two read doors had not. + +Direction is **fail-closed**: the caller's OWN org data was under-served. There +was no cross-org read, and the pins added here keep it that way (an org-less +caller, and a second organization, are both refused the rows). + +**Call-side only.** `packages/spec` and the protocol implementation are +untouched: `organizationId` was already declared on the request contract +(`HistoryMetaItemRequestSchema`), and `request.organizationId ?? null` is the +legitimate expression of env scope that every correct caller depends on. + +**The scope predicate is `organizationIdForMetaRead`, not the audit twin's raw +`ctx?.tenantId ?? null`** — measured, not stylistic. `auditMetaItem` reads with +`$or: [{organization_id: org}, {organization_id: null}]`, a union, so naming a +tenant there can only add rows. Under these doors' strict equality, a raw tenant +id would ask the **org** partition for the history of every +`allowOrgOverride: false` type that is still runtime-writable (`object`, `hook`, +`page`, `app`, `dataset`) — types whose rows `organizationIdForMetaWrite` +deliberately lands **env-wide** under the #6190 ruling. That would answer +`{ events: [] }` for them: this same defect, newly minted one type family over. +Gating the read on the same registry predicate the write uses is what keeps the +two sides incapable of drifting. + +The key is **spread, never `organizationId: x ?? null`**: +`HistoryMetaItemRequestSchema` declares `z.string().optional()` — optional plain +`string`, not nullable, mirroring the implementation's `organizationId?: string` +— so `?? null` is a compile error on the history door, and on the diff door +(reached through a cast) it type-checks and is a silent runtime no-op. + +Users of a single-DB multi-org deployment (`OS_TENANCY_POSTURE=isolated`) now +see the change log and version diffs for overlays their own organization +authored. Org-less callers, and every `allowOrgOverride: false` type, read +exactly what they read before. diff --git a/packages/rest/src/execctx-consumer-census.test.ts b/packages/rest/src/execctx-consumer-census.test.ts index 3260e9262a..763a6079ea 100644 --- a/packages/rest/src/execctx-consumer-census.test.ts +++ b/packages/rest/src/execctx-consumer-census.test.ts @@ -309,7 +309,23 @@ describe('[#13160] §1 the production supplier fulfils with `undefined` rather t // --------------------------------------------------------------------------- describe('[#13160] §2 the consumer surface, counted from the tree', () => { - it('73 invocation sites, 92 mentions — the thread\'s two control numbers hold', () => { + it('75 invocation sites, 95 mentions — the thread\'s two control numbers hold', () => { + // [#13406] 73 → 75 sites / 92 → 95 mentions. The `/meta/:type/:name/ + // history` and `/diff` read doors resolved NO identity, so neither + // could state which organization's `sys_metadata_history` partition it + // was reading — they read the env one and answered an empty change log + // for org-scoped overlays. Both join as LOCALLY CAUGHT sites (the + // continuation-line `.catch(rethrowAuthzStoreUnavailable)` spelling), + // which is the family the next case describes: neither door sits behind + // the shared anonymous floor, so each must decide the outage for + // itself — the same shape the `/audit` twin and the `/layers` door + // already carry. + // + // ⚠️ Again the two numbers moved by DIFFERENT amounts (+2 and +3): two + // call sites, and ONE prose mention in the history door's new + // doc-comment recording that `resolveExecCtx` is memoised per request + // and so this is not a new org-resolution seam. + // // [#13214] 72 → 73 sites / 89 → 92 mentions. `registerUiEndpoints` was // the ONE metadata-touching route in the table that resolved no // identity at all — the exception this census surfaced — and the @@ -324,11 +340,11 @@ describe('[#13160] §2 the consumer surface, counted from the tree', () => { // `enforceAuth` was measured NOT to be the repair). A mention count // that tracked the site count exactly would be measuring one thing // twice. - expect(SITES.length).toBe(73); - expect(SOURCE.split('resolveExecCtx').length - 1).toBe(92); + expect(SITES.length).toBe(75); + expect(SOURCE.split('resolveExecCtx').length - 1).toBe(95); }); - it('the split is 20 locally caught / 53 bare — NOT 16 / 53, which does not add to 73', () => { + it('the split is 22 locally caught / 53 bare — NOT 16 / 53, which does not add to 75', () => { // 16 sites spell the catch on the invocation line; 4 more spell it on // the continuation line. A single-line grep sees 16 and the arithmetic // silently loses four sites. @@ -338,12 +354,12 @@ describe('[#13160] §2 the consumer surface, counted from the tree', () => { // be the first of its kind and would break the structural claim below. const sameLine = CAUGHT.filter((s) => SOURCE.split('\n')[s.line - 1].includes('.catch(')); expect(sameLine.length).toBe(16); - expect(CAUGHT.length).toBe(20); + expect(CAUGHT.length).toBe(22); expect(BARE.length).toBe(53); expect(CAUGHT.length + BARE.length).toBe(SITES.length); }); - it('⭐ every one of the 53 bare sites is guarded on the VERY NEXT LINE, and none of the 20 caught ones is', () => { + it('⭐ every one of the 53 bare sites is guarded on the VERY NEXT LINE, and none of the 22 caught ones is', () => { // This inverts the reason the thread gave for doing the bare sites // first ("no local signal that a fault becomes an anonymous subject"). // The bare sites are bare BECAUSE the shared anonymous floor is the From 4085f702ff33b9e70c81af6c2eb32feb1055e067 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 09:17:03 +0000 Subject: [PATCH 3/6] chore(docs): re-anchor the system-context census after the rest-server line shift (#13406) --- content/docs/permissions/system-context.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index 5a4eb85ac7..aba8f55d6b 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -158,7 +158,7 @@ The largest single consumer — **20 of the 109 sites**. |:--|:---|:---|:---|:---| | 48 | Object API-exposure gate bypassed (`apiEnabled` / `apiMethods`) | runtime | Get: internal self-writes ignore exposure declarations — these govern **external** exposure, not engine self-writes | `action-execution.ts:136` | | 49 | Action `requiredPermissions` bypassed | runtime | Get: engine self-invocation runs any action | `action-execution.ts:399` | -| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:4408`, `:5771`, `:6019`, `:6382`, `:6575` | +| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:4408`, `:5771`, `:6019`, `:6432`, `:6625` | | 51 | The shared metadata-write verdict itself returns `allowed` | metadata-core | Get: the one function all of row 50's doors consult answers yes before any capability is examined | `meta-write-capability.ts:134` | | 52 | Anonymous-deny seam satisfied on the domain dispatchers and the package/federation routes | runtime, rest | Get: passes with no `userId` | `domains/actions.ts:411`, `domains/ai.ts:60`, `domains/automation.ts:989`, `domains/meta.ts:232`, `domains/security.ts:78`, `domains/packages.ts:246`, `external-datasource-routes.ts:302`, `package-routes.ts:97` | | 53 | MCP principal check satisfied | runtime | Get: MCP surface reachable with no user | `domains/mcp.ts:61` | From 0df058f2aee1dafe16dee7203a333c96bff98178 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 09:23:30 +0000 Subject: [PATCH 4/6] test(rest): refuse unsupported WHERE combinators in the stub double; register its pins (#13406) --- ...est-server-meta-history-diff-org-scope.test.ts | 13 ++++++++++++- scripts/engine-double-contract.pinned.json | 15 +++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/packages/rest/src/rest-server-meta-history-diff-org-scope.test.ts b/packages/rest/src/rest-server-meta-history-diff-org-scope.test.ts index 6428fcd100..6684a5db17 100644 --- a/packages/rest/src/rest-server-meta-history-diff-org-scope.test.ts +++ b/packages/rest/src/rest-server-meta-history-diff-org-scope.test.ts @@ -137,11 +137,22 @@ const keyOf = (w: Record) => function matchesWhere(r: Record, where: Record): boolean { for (const [k, v] of Object.entries(where)) { if (k === '$or') { + // Conjoined with its siblings, not early-returned: the loop + // CONTINUES, so the remaining keys still have to match. const clauses = v as Array>; if (!clauses.some((c) => matchesWhere(r, c))) return false; continue; } - if (k === 'context') continue; + // ⛔ REFUSE any other combinator rather than reading it as a field + // name. `$or` is the only one the read paths under test emit + // (`auditMetaItem`'s union, and the repository's draft lookup), and a + // double that answered `$and` by looking for a column literally called + // `$and` would return a well-formed WRONG answer — the silent class + // `check:where-matcher` exists to catch. Refusing loudly is the + // convention most doubles in this repo already follow. + if (k.startsWith('$')) { + throw new Error(`stub engine: unsupported WHERE combinator \`${k}\``); + } if (v === undefined) continue; if (r[k] !== v) return false; } diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index 5c7ff4deb6..7a6004b4d0 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -2856,6 +2856,21 @@ "verb": "update", "pinned": 1 }, + { + "file": "packages/rest/src/rest-server-meta-history-diff-org-scope.test.ts", + "verb": "delete", + "pinned": 1 + }, + { + "file": "packages/rest/src/rest-server-meta-history-diff-org-scope.test.ts", + "verb": "findOne", + "pinned": 1 + }, + { + "file": "packages/rest/src/rest-server-meta-history-diff-org-scope.test.ts", + "verb": "update", + "pinned": 1 + }, { "file": "packages/rest/src/rest-server-meta-read-org-scope.test.ts", "verb": "delete", From d73a967dd0e11470a48c0e53b25e760e3f13750a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 10:02:02 +0000 Subject: [PATCH 5/6] =?UTF-8?q?docs(rest):=20the=20history=20door's=20`=3F?= =?UTF-8?q?=3F=20null`=20is=20TS2322,=20not=20TS2353=20=E2=80=94=20name=20?= =?UTF-8?q?the=20adjacency=20drift=20and=20the=20diff=20door's=20absent=20?= =?UTF-8?q?guard=20(#13406)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- content/docs/permissions/system-context.mdx | 2 +- ...server-meta-history-diff-org-scope.test.ts | 22 +++++++++++++++---- packages/rest/src/rest-server.ts | 22 +++++++++++++++++-- 3 files changed, 39 insertions(+), 7 deletions(-) diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index aba8f55d6b..3897d7133d 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -158,7 +158,7 @@ The largest single consumer — **20 of the 109 sites**. |:--|:---|:---|:---|:---| | 48 | Object API-exposure gate bypassed (`apiEnabled` / `apiMethods`) | runtime | Get: internal self-writes ignore exposure declarations — these govern **external** exposure, not engine self-writes | `action-execution.ts:136` | | 49 | Action `requiredPermissions` bypassed | runtime | Get: engine self-invocation runs any action | `action-execution.ts:399` | -| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:4408`, `:5771`, `:6019`, `:6432`, `:6625` | +| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:4408`, `:5771`, `:6019`, `:6450`, `:6643` | | 51 | The shared metadata-write verdict itself returns `allowed` | metadata-core | Get: the one function all of row 50's doors consult answers yes before any capability is examined | `meta-write-capability.ts:134` | | 52 | Anonymous-deny seam satisfied on the domain dispatchers and the package/federation routes | runtime, rest | Get: passes with no `userId` | `domains/actions.ts:411`, `domains/ai.ts:60`, `domains/automation.ts:989`, `domains/meta.ts:232`, `domains/security.ts:78`, `domains/packages.ts:246`, `external-datasource-routes.ts:302`, `package-routes.ts:97` | | 53 | MCP principal check satisfied | runtime | Get: MCP surface reachable with no user | `domains/mcp.ts:61` | diff --git a/packages/rest/src/rest-server-meta-history-diff-org-scope.test.ts b/packages/rest/src/rest-server-meta-history-diff-org-scope.test.ts index 6684a5db17..d5fa49d8d1 100644 --- a/packages/rest/src/rest-server-meta-history-diff-org-scope.test.ts +++ b/packages/rest/src/rest-server-meta-history-diff-org-scope.test.ts @@ -36,10 +36,24 @@ // it, and it is the whole reason this file exists in this shape. // 2. `HistoryMetaItemRequestSchema` declares `organizationId: // z.string().optional()` — optional plain string, NOT nullable, mirroring -// the implementation's `organizationId?: string`. `?? null` on the history -// door is a TS2353 compile error; on the diff door — reached through -// `(p as any)` — it type-checks and is a silent RUNTIME no-op, since -// `null ?? null` is `null`. Hence the omit-spread on both. +// the implementation's `organizationId?: string`. The two doors then fail +// DIFFERENTLY, and the asymmetry is the reason the omit-spread is on both: +// +// • `/history` reddens with **TS2322** — measured: `Type 'string | null' +// is not assignable to type 'string | undefined'`. An ASSIGNABILITY +// failure. ⚠️ NOT TS2353, which is the UNDECLARED-member code: +// `organizationId` IS declared, so the unknown-property code cannot +// apply. (Both this line and the door's own comment said TS2353 when +// they landed, copied from a neighbouring paragraph that is about +// undeclared members and is correct in its own context — comment drift +// by adjacency, corrected and named rather than quietly fixed.) +// +// • `/diff` reddens with **NOTHING**. It reaches `diffMetaItem` through +// `(p as any)`, so the compiler checks nothing about that literal: +// `?? null` type-checks there and is a silent RUNTIME no-op, since +// `null ?? null` is `null`. ⇒ the guard is WEAKEST exactly where the +// argument is most easily assumed to be strongest, and on that door +// the spread is the ONLY thing holding the contract. // // ── Why the harness is the REAL protocol, not a spy ─────────────────────── // diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index afe5b30b15..b58d2056f5 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -6229,8 +6229,26 @@ export class RestServer { // `organizationId?: string` — and the spec's own describe // text names the asymmetry against the audit twin, which // declares `string | null`. Copying the audit door's - // expression here is a TS2353 compile error, and would be a - // no-op at runtime anyway (`null ?? null` is `null`). + // expression here is a **TS2322** compile error, measured: + // `error TS2322: Type 'string | null' is not assignable to + // type 'string | undefined'`. It is also a no-op at runtime + // (`null ?? null` is `null`). + // + // ⚠️ TS2322, NOT the TS2353 the paragraph directly above + // names, and the difference is the whole point: TS2353 is + // the UNDECLARED-member code, and `organizationId` IS + // declared — so this is an assignability failure, not an + // unknown-property one. This comment said TS2353 when it + // landed, copied from its neighbour nine lines up, which is + // correct in ITS context and wrong here. Comment drift by + // adjacency; named so the next reader standing in the same + // spot does not repeat it. + // + // ⚠️ And the guard is WEAKER one door over, not stronger: + // the `/diff` twin reaches `diffMetaItem` through + // `(p as any)`, so `?? null` there reddens with NOTHING and + // is a silent runtime no-op. Do not generalise "the + // compiler catches this" from here to that door. const historyRequest: TransportScopedMetaRequest = { type: req.params.type, name: req.params.name, From a18a9a48897c8497e5ea36cd454b860158c7c642 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 10:54:12 +0000 Subject: [PATCH 6/6] test(rest): the history/diff stub double holds the caller's limit bound, and a pin proves ?limit= travels the door (#13406) --- ...server-meta-history-diff-org-scope.test.ts | 61 +++++++++++++++++-- 1 file changed, 56 insertions(+), 5 deletions(-) diff --git a/packages/rest/src/rest-server-meta-history-diff-org-scope.test.ts b/packages/rest/src/rest-server-meta-history-diff-org-scope.test.ts index d5fa49d8d1..7ce88c6f9e 100644 --- a/packages/rest/src/rest-server-meta-history-diff-org-scope.test.ts +++ b/packages/rest/src/rest-server-meta-history-diff-org-scope.test.ts @@ -205,7 +205,26 @@ function makeStubEngine() { } return findRow(opts.where)?.row ?? null; }, - async find(table: string, opts?: { where?: Record }) { + async find(table: string, opts?: { where?: Record; limit?: number }) { + // ⛔ THE CALLER'S BOUND IS HELD, and applied AFTER the filter. + // `/history` accepts `?limit=` and threads it all the way down, so + // `limit` is a contract member of the very door this file pins — a + // double that silently ignored it would sit GREEN through a + // regression that dropped the bound on the way to + // `historyMetaItem`. That is the identical argument to refusing an + // unknown `$` combinator above: a parameter the door really + // carries, answered wrongly but well-formedly, is the failure this + // whole file exists to make impossible. Applied on BOTH tables so + // the two branches cannot disagree. + // + // AFTER the filter, never before: bounding first would decide which + // rows survive the predicate rather than how many of the survivors + // are returned. By PRESENCE (`typeof === 'number'`), so the calls + // that pass no bound — every call the repository makes today, since + // `SysMetadataRepository.history()` applies `limit` itself while + // iterating rather than pushing it into the engine — are untouched + // and every existing assertion keeps its meaning. + // `check:objectql-double-limit`. if (table === 'sys_metadata_history') { // ⭐ THE POSITIVE CONTROL'S FOUNDATION. `history()` and // `diffMetaItem` both filter `organization_id` by strict @@ -213,13 +232,15 @@ function makeStubEngine() { // identically and no org-scoping assertion here could ever // fail. Partitioning the stub is what makes the door's // behaviour observable at all. - return historyRows.filter( + const matched = historyRows.filter( (h) => matchesWhere(h as unknown as Record, opts?.where ?? {}), ); + return typeof opts?.limit === 'number' ? matched.slice(0, opts.limit) : matched; } - return Array.from(rows.values()).filter( + const matched = Array.from(rows.values()).filter( (r) => matchesWhere(r as unknown as Record, opts?.where ?? {}), ); + return typeof opts?.limit === 'number' ? matched.slice(0, opts.limit) : matched; }, async insert(table: string, data: Record) { if (table === 'sys_metadata_audit') return { id: 'audit_skip' }; @@ -332,8 +353,8 @@ function boot() { drive('PUT', `${META}/:type/:name`, { params: { type, name }, body: bodyFor(type, name, label) }), get: (type: string, name: string) => drive('GET', `${META}/:type/:name`, { params: { type, name } }), - history: (type: string, name: string) => - drive('GET', `${META}/:type/:name/history`, { params: { type, name } }), + history: (type: string, name: string, query: Record = {}) => + drive('GET', `${META}/:type/:name/history`, { params: { type, name }, query }), diff: (type: string, name: string, query: Record = {}) => drive('GET', `${META}/:type/:name/diff`, { params: { type, name }, query }), /** ⭐ The fixture proof every read assertion below is gated on. */ @@ -391,6 +412,36 @@ describe('#13406 the /history and /diff read doors state the org partition', () }); }); + describe('/history honours the caller\'s bound', () => { + it('?limit=1 over a two-revision log returns exactly the first event', async () => { + // Added with the `check:objectql-double-limit` repair rather than + // separately from it: the gate's finding was that the stub could + // not OBSERVE `limit`, and the honest close of that is a pin that + // proves the bound now travels the whole door — query string -> + // `historyMetaItem` -> `repo.history()`'s `yielded >= limit` break. + // Fixing the double alone would have satisfied the gate while + // leaving this contract member as untested as it was. + await b.put('view', 'bounded'); + await b.put('view', 'bounded', MARKER_2); + expect(b.historyRowsFor('view', 'bounded', ORG_A).map((h) => h.version)).toEqual([1, 2]); + + // The CONTROL, without which "1 event came back" proves nothing: + // the same read with no bound must return both. + const all = await b.history('view', 'bounded'); + expect(all.body?.events?.length, 'the unbounded control did not see both revisions').toBe(2); + + const bounded = await b.history('view', 'bounded', { limit: '1' }); + expect(bounded.thrown, `bounded read threw: ${bounded.thrown?.message}`).toBeUndefined(); + expect(bounded.status).toBe(200); + expect(bounded.body?.events?.length, 'the caller\'s ?limit= was dropped').toBe(1); + // Oldest-first (the response contract is `seq` order, the opposite + // end of the log from the audit twin), so a bound of 1 keeps + // revision 1 — naming WHICH event guards against a bound that + // truncates from the wrong end. + expect(bounded.body.events[0].version).toBe(1); + }); + }); + describe('/diff resolves org-scoped versions', () => { it.each(ORG_OVERRIDABLE)('%s: ?from=1&to=2 compares the two org revisions', async (type) => { await b.put(type, 'two_revisions');