From e669f8d0c112ede14b554e53cf7c89e6cc10c9aa Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 03:09:56 +0000 Subject: [PATCH 1/4] test(metadata-protocol): pin which organization value the cached ETag folds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `getMetaItemCached` folds `request.organizationId` — the SUPPLIED member — into the ETag scope prefix, not the effective scope `organizationIdForMetaRead` returns. Measured and pinned end to end: §1 a type declared `allowOrgOverride: false` gates the supplied organization away, serves the env-wide record, and still issues an organization-named validator — byte-identical bodies, two validators. §2 the "resolve an org row, fall back to env-wide" path the fold was written against is present today, reachable on an overridable type whose organization has no row, with the control that the harness can resolve an org row when one exists. §3 the served bytes are inside the same hash, so a validator can never pin a caller to a representation it did not receive: unchanged document 304s (the control that must fire), a changed document never does, and one organization's validator is never honoured for another. §3 is the half that makes §1 a cost rather than a fault, and the half a hash change would break silently. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU --- .../get-meta-item-cached-etag-scope.test.ts | 337 ++++++++++++++++++ 1 file changed, 337 insertions(+) create mode 100644 packages/metadata-protocol/src/get-meta-item-cached-etag-scope.test.ts diff --git a/packages/metadata-protocol/src/get-meta-item-cached-etag-scope.test.ts b/packages/metadata-protocol/src/get-meta-item-cached-etag-scope.test.ts new file mode 100644 index 0000000000..bcfed9ce94 --- /dev/null +++ b/packages/metadata-protocol/src/get-meta-item-cached-etag-scope.test.ts @@ -0,0 +1,337 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#16525] WHICH organization value `getMetaItemCached` folds into the ETag — + * the SUPPLIED request member, or the EFFECTIVE scope the registry read gate + * returns — and why the answer is not the defect the card feared. + * + * ── The question, and the measurement ───────────────────────────────────── + * + * The card put two facts side by side. `organizationIdForMetaRead(type, org)` + * answers `undefined` for a supplied organization whenever the registry + * declares `allowOrgOverride: false`, and `getMetaItem` then resolves + * `(orgId ? findOverlay(orgId) : undefined) ?? findOverlay(null)` — so a + * gated-away organization is served the env-wide record. `getMetaItemCached` + * delegates to that. ⇒ If the ETag names the SUPPLIED organization while the + * body is the env-wide representation, the validator states a scope that was + * never served. + * + * MEASURED, and the answer is the SUPPLIED member: + * + * const scope = [ + * request.organizationId ? `org:${request.organizationId}` : undefined, + * request.locale || undefined, + * ].filter(...); + * + * §1 reproduces the divergence end to end — `object` is `allowOrgOverride: + * false`, a supplied organization is gated away, the env-wide record is + * served, and the validator still names the organization. + * + * ── ⭐ Why that is a COST and not a correctness fault ────────────────────── + * + * Because `content` — the serialized document actually being sent — is inside + * the same hash. The validator is therefore a function of the bytes, not only + * of the declared scope, so the harm the card names ("a validator claiming to + * describe an org-scoped representation that was never served") cannot reach + * a caller: a `304` is answered only on an exact match of a hash that covers + * those bytes, so a caller is only ever pinned to the representation IT + * previously received. §3 pins that, and it is the half a future change is + * most likely to break silently — hashing a cheap version marker instead of + * the document would leave §1 and §2 green and destroy the whole argument. + * + * What is left is validator FRAGMENTATION: N organizations reading one + * env-wide document through a non-overridable type hold N validators for + * byte-identical content. Wasteful, not wrong. + * + * ── ⚠️ §1 is a CHARACTERIZATION pin, not a prohibition ──────────────────── + * + * Folding the EFFECTIVE value instead would merge those validators and make + * the declared scope true. It is deliberately NOT done here: it changes every + * published ETag that carries an organization (one forced miss per caller per + * deploy) and buys nothing at the only production door, which already supplies + * the effective value (pinned in `@objectstack/rest`, + * `rest-server-meta-cached-etag-door-scope.test.ts`). ⇒ If §1 reddens, read + * #16525 before making it green: it means the fold moved, which is a decision, + * not a regression. + * + * ── Why the observation channel is a BODY-vs-VALIDATOR pair ─────────────── + * + * Asserting an ETag literal would pin the hash function, which is not what + * this card is about. Every assertion below compares two reads that differ in + * exactly one input, and asserts the served BODIES alongside the validators — + * so an ETag difference can never be attributed to a body difference nobody + * checked, and a body difference can never hide behind a validator nobody + * read. + */ + +import { describe, expect, it } from 'vitest'; +import { organizationIdForMetaRead } from '@objectstack/metadata-core'; +import { DEFAULT_METADATA_TYPE_REGISTRY } from '@objectstack/spec/kernel'; +import { ObjectStackProtocolImplementation } from './protocol.js'; + +const ORG = 'org_acme'; +const OTHER_ORG = 'org_globex'; + +/** `allowOrgOverride: false` — a supplied organization is gated away. */ +const NON_OVERRIDABLE = 'object'; + +/** `allowOrgOverride: true` — a supplied organization survives the gate. */ +const OVERRIDABLE = 'view'; + +interface StoredRow { + id: string; + type: string; + name: string; + organization_id: string | null; + package_id: string | null; + state: string; + metadata: string; +} + +/** + * A row whose `label` names the partition it came from, so a served document + * says which row answered without the assertion having to guess. + */ +const storedRow = ( + type: string, + name: string, + extra: Partial = {}, +): StoredRow => ({ + id: `r_${type}_${name}_${extra.organization_id ?? 'env'}`, + type, + name, + organization_id: null, + package_id: null, + state: 'active', + metadata: JSON.stringify({ name, label: `${extra.organization_id ?? 'env'} ${name}` }), + ...extra, +}); + +/** + * The engine double: `findOne` over a row table, plus the registry surface the + * single-item read path touches on its way past the overlay. + * + * ⛔ No `find` / `insert` / `update` / `delete`, deliberately — the read path + * under test issues exactly one verb, and a double declaring verbs no case + * exercises would owe `check:engine-double-contract` a dispatch contract that + * protects nothing. Same shape the sibling org-read-gate pin drives. + */ +function makeHarness(rows: StoredRow[]) { + const engine: any = { + async findOne(table: string, opts?: { where?: Record }) { + if (table !== 'sys_metadata') return undefined; + const where = opts?.where ?? {}; + // `check:where-matcher` — a hand-written matcher with no combinator + // branch reads `$and` as a field name and answers the wrong + // question rather than failing. Refuse the shape this double does + // not implement, matching the sibling doubles' convention. + for (const k of Object.keys(where)) { + if (k.startsWith('$')) { + throw new Error(`[test double] unsupported WHERE combinator '${k}'`); + } + } + return rows.find((r) => + Object.entries(where).every(([k, v]) => { + if (v === undefined) return true; + return (r as unknown as Record)[k] === v; + }), + ); + }, + registry: { + registerItem: () => undefined, + registerObject: () => undefined, + listItems: () => [], + getItem: () => undefined, + getObject: () => undefined, + getPackage: () => undefined, + getArtifactItem: () => undefined, + isPackageDisabled: () => false, + applyNavContributions: (app: unknown) => app, + }, + }; + return new ObjectStackProtocolImplementation(engine, () => new Map()) as any; +} + +/** The label the served document carries — `'env …'` or `'org_acme …'`. */ +const servedLabel = (res: any): string => res?.data?.label; + +// ═══════════════════════════════════════════════════════════════════════════ +// §0 — the registry facts every section below rests on, read not assumed +// ═══════════════════════════════════════════════════════════════════════════ + +describe('§0 the fixture types really are on opposite sides of the read gate', () => { + it(`${NON_OVERRIDABLE} is declared allowOrgOverride: false`, () => { + const entry = DEFAULT_METADATA_TYPE_REGISTRY.find((e) => e.type === NON_OVERRIDABLE); + expect(entry, `no registry entry for '${NON_OVERRIDABLE}'`).toBeDefined(); + expect(entry!.allowOrgOverride).toBe(false); + expect(organizationIdForMetaRead(NON_OVERRIDABLE, ORG)).toBeUndefined(); + }); + + it(`${OVERRIDABLE} is declared allowOrgOverride: true`, () => { + const entry = DEFAULT_METADATA_TYPE_REGISTRY.find((e) => e.type === OVERRIDABLE); + expect(entry, `no registry entry for '${OVERRIDABLE}'`).toBeDefined(); + expect(entry!.allowOrgOverride).toBe(true); + expect(organizationIdForMetaRead(OVERRIDABLE, ORG)).toBe(ORG); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// §1 — (a) WHICH VALUE. The ETag folds the SUPPLIED member. +// ⚠️ Characterization. If this reddens, read #16525 before greening it. +// ═══════════════════════════════════════════════════════════════════════════ + +describe('§1 the ETag names the SUPPLIED organization, not the effective scope', () => { + it('a gated-away organization is absent from the BODY and present in the VALIDATOR', async () => { + // A pre-#6190 phantom org row sits beside the env-wide one. The gate is + // what keeps it out of the response; without it this case would prove + // nothing about the ETag, because the two reads would differ in body. + const protocol = makeHarness([ + storedRow(NON_OVERRIDABLE, 'customer'), + storedRow(NON_OVERRIDABLE, 'customer', { organization_id: ORG, id: 'phantom' }), + ]); + + const supplied = await protocol.getMetaItemCached({ + type: NON_OVERRIDABLE, name: 'customer', organizationId: ORG, + }); + const orgless = await protocol.getMetaItemCached({ + type: NON_OVERRIDABLE, name: 'customer', + }); + + // ⭐ The control that makes the validator assertion mean anything: the + // gate held, so BOTH reads were answered by the env-wide row and the + // phantom was not served. + expect(servedLabel(supplied), 'the phantom org row was served').toBe('env customer'); + expect(servedLabel(orgless)).toBe('env customer'); + expect(JSON.stringify(supplied.data)).toBe(JSON.stringify(orgless.data)); + + // ⇒ Byte-identical representations, different validators. The scope + // component is the request member, unreduced by the read gate. + expect( + supplied.etag.value, + 'the ETag no longer distinguishes a gated-away organization — see #16525', + ).not.toBe(orgless.etag.value); + }); + + it('two organizations gated away from the same document hold two validators', async () => { + const protocol = makeHarness([storedRow(NON_OVERRIDABLE, 'customer')]); + + const a = await protocol.getMetaItemCached({ + type: NON_OVERRIDABLE, name: 'customer', organizationId: ORG, + }); + const b = await protocol.getMetaItemCached({ + type: NON_OVERRIDABLE, name: 'customer', organizationId: OTHER_ORG, + }); + + expect(JSON.stringify(a.data)).toBe(JSON.stringify(b.data)); + // The fragmentation the card names. Wasteful, and harmless for the + // reason §3 pins. + expect(a.etag.value).not.toBe(b.etag.value); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// §2 — (b) REACHABILITY of the fallback the fold was written against +// ═══════════════════════════════════════════════════════════════════════════ + +describe('§2 the org-resolved-then-env-wide fallback is present, not future', () => { + /** + * The fold's own comment argued from "any FUTURE path that resolves an org + * row but falls back to the env-wide body". That path is the `??` in + * `getMetaItem` and it runs today on an OVERRIDABLE type whose + * organization simply has no row — reachable through the production + * cached door, which forwards the organization for exactly these types. + */ + it('an overridable type with no org row serves env-wide under an org-named validator', async () => { + const protocol = makeHarness([storedRow(OVERRIDABLE, 'account_list')]); + + const supplied = await protocol.getMetaItemCached({ + type: OVERRIDABLE, name: 'account_list', organizationId: ORG, + }); + const orgless = await protocol.getMetaItemCached({ + type: OVERRIDABLE, name: 'account_list', + }); + + // The organization survived the gate here — and still resolved nothing, + // so the env-wide row answered. + expect(organizationIdForMetaRead(OVERRIDABLE, ORG)).toBe(ORG); + expect(servedLabel(supplied)).toBe('env account_list'); + expect(JSON.stringify(supplied.data)).toBe(JSON.stringify(orgless.data)); + expect(supplied.etag.value).not.toBe(orgless.etag.value); + }); + + it('the same type WITH an org row serves it, so the fallback above was a real fallback', async () => { + // ⭐ Without this control the case above is indistinguishable from a + // harness that can never resolve an org row at all. + const protocol = makeHarness([ + storedRow(OVERRIDABLE, 'account_list'), + storedRow(OVERRIDABLE, 'account_list', { organization_id: ORG, id: 'org_row' }), + ]); + + const supplied = await protocol.getMetaItemCached({ + type: OVERRIDABLE, name: 'account_list', organizationId: ORG, + }); + expect(servedLabel(supplied)).toBe(`${ORG} account_list`); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// §3 — ⭐ WHY §1 IS A COST AND NOT A FAULT: the served bytes are in the hash +// ═══════════════════════════════════════════════════════════════════════════ + +describe('§3 the validator is a function of the served bytes, not of the scope alone', () => { + it('an unchanged document answers 304 to the validator it issued', async () => { + // ⭐ The control that must FIRE. Every assertion below reads "no 304"; + // if the 304 arm were unreachable in this harness they would all pass + // for the wrong reason. + const rows = [storedRow(NON_OVERRIDABLE, 'customer')]; + const protocol = makeHarness(rows); + + const first = await protocol.getMetaItemCached({ + type: NON_OVERRIDABLE, name: 'customer', organizationId: ORG, + }); + const again = await protocol.getMetaItemCached({ + type: NON_OVERRIDABLE, name: 'customer', organizationId: ORG, + cacheRequest: { ifNoneMatch: `"${first.etag.value}"` }, + }); + + expect(again.notModified, 'the 304 arm is unreachable in this harness').toBe(true); + }); + + it('a changed document never answers 304, though the scope is unchanged', async () => { + // This is the invariant the whole "harmless" argument rests on. A hash + // over the scope and a cheap version marker — a plausible optimization — + // leaves §1 and §2 green and turns this red. + const rows = [storedRow(NON_OVERRIDABLE, 'customer')]; + const protocol = makeHarness(rows); + + const first = await protocol.getMetaItemCached({ + type: NON_OVERRIDABLE, name: 'customer', organizationId: ORG, + }); + + rows[0].metadata = JSON.stringify({ name: 'customer', label: 'env customer REVISED' }); + + const after = await protocol.getMetaItemCached({ + type: NON_OVERRIDABLE, name: 'customer', organizationId: ORG, + cacheRequest: { ifNoneMatch: `"${first.etag.value}"` }, + }); + + expect(after.notModified, 'a stale validator was honoured across a body change').toBe(false); + expect(servedLabel(after)).toBe('env customer REVISED'); + expect(after.etag.value).not.toBe(first.etag.value); + }); + + it("one organization's validator is never honoured for another's request", async () => { + const protocol = makeHarness([storedRow(NON_OVERRIDABLE, 'customer')]); + + const mine = await protocol.getMetaItemCached({ + type: NON_OVERRIDABLE, name: 'customer', organizationId: ORG, + }); + const theirs = await protocol.getMetaItemCached({ + type: NON_OVERRIDABLE, name: 'customer', organizationId: OTHER_ORG, + cacheRequest: { ifNoneMatch: `"${mine.etag.value}"` }, + }); + + expect(theirs.notModified).toBe(false); + expect(servedLabel(theirs)).toBe('env customer'); + }); +}); From 8016d023850ae6b61c3ba6ecdc7d1fa24b7d97f7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 03:15:40 +0000 Subject: [PATCH 2/4] test(rest): pin that the cached /meta door pre-gates the organization The cached door computes `organizationIdForMetaRead(canonicalMetaUrlType( req.params.type), readCtx?.tenantId)` above the cached/uncached fork and spreads THAT into the `getMetaItemCached` request. Because the protocol folds the member as handed in when it computes the ETag, this reduction is what keeps the validator's declared scope equal to the scope the body was resolved under. Nothing pinned it. The sibling `rest-server-meta-read-org-scope.test.ts` records the gap in its own words -- "it pins that the arm still FOLDS, never that the fold happens at the door" -- and its measured ablation confirms it: swapping the door's predicate for a raw tenant leaves that file green, because the callee re-folds. The callee re-folds for the BODY, not for the validator. Observed through the ETag response header across two reads of one document differing only in the session's active organization: identical on a non-overridable type, and -- the control that must fire -- different on an overridable one. Plus the counterfactual, asking the protocol directly with the raw tenant. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU --- ...server-meta-cached-etag-door-scope.test.ts | 349 ++++++++++++++++++ 1 file changed, 349 insertions(+) create mode 100644 packages/rest/src/rest-server-meta-cached-etag-door-scope.test.ts diff --git a/packages/rest/src/rest-server-meta-cached-etag-door-scope.test.ts b/packages/rest/src/rest-server-meta-cached-etag-door-scope.test.ts new file mode 100644 index 0000000000..d26a7ce9a7 --- /dev/null +++ b/packages/rest/src/rest-server-meta-cached-etag-door-scope.test.ts @@ -0,0 +1,349 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#16525] The REST cached `/meta` door hands `getMetaItemCached` the EFFECTIVE + * organization, already reduced by the registry read gate — so the validator's + * scope prefix and the representation it validates agree at the only door that + * reaches this verb in production. + * + * ── Why this file exists ────────────────────────────────────────────────── + * + * `getMetaItemCached` folds `request.organizationId` — the value AS SUPPLIED — + * into the ETag (pinned in `@objectstack/metadata-protocol`, + * `get-meta-item-cached-etag-scope.test.ts` §1). On its own that is a validator + * that can name a scope the body was never resolved under. What makes it + * agree in production is one line at THIS door: + * + * const readOrganizationId = organizationIdForMetaRead( + * canonicalMetaUrlType(req.params.type), readCtx?.tenantId, + * ); + * + * computed above the cached/uncached fork and spread into the cached request. + * The gate has already run by the time the protocol sees the member, and + * `organizationIdForMetaRead` is idempotent, so supplied === effective here. + * + * ⭐ NOTHING PINNED THAT. The sibling `rest-server-meta-read-org-scope.test.ts` + * says so in its own words — "it pins that the arm still FOLDS, never that the + * fold happens at the door" — and its measured ablation confirms it: swapping + * the door's predicate for a raw `ctx?.tenantId` leaves that file green, + * because the callee re-folds. It re-folds for the BODY. It does not re-fold + * for the ETag, which is computed one layer above from the member as handed in. + * ⇒ That ablation is exactly the change this file exists to redden. + * + * ── The observation channel, and the control ────────────────────────────── + * + * The `ETag` response header, compared across two reads of ONE document that + * differ only in the session's active organization. + * + * • a NON-overridable type: the two validators must be IDENTICAL, because + * the door reduced both organizations to `undefined`. + * • ⭐ THE CONTROL THAT MUST FIRE — an OVERRIDABLE type: the two validators + * must DIFFER, because the door forwards the organization there. Without + * it, a harness that emitted no ETag at all, or never took the cached arm, + * would pass every assertion above by answering `undefined === undefined`. + * + * §3 closes the loop by asking the protocol the counterfactual directly: given + * the RAW tenant the door started from, the validator it would issue differs + * from the one the door actually issued. That is the whole of what the door's + * pre-gate buys, stated as a difference rather than as an intention. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { 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'; + +/** `allowOrgOverride: false` — the door must reduce the organization away. */ +const NON_OVERRIDABLE = 'object'; + +/** `allowOrgOverride: true`, and the type that takes the CACHED arm. */ +const OVERRIDABLE = 'view'; + +const NAME = 'shared_document'; + +interface Row { + id: string; type: string; name: string; + organization_id: string | null; package_id: string | null; + state: string; metadata: string; +} + +const row = (type: string, organization_id: string | null = null): Row => ({ + id: `r_${type}_${organization_id ?? 'env'}`, + type, + name: NAME, + organization_id, + package_id: null, + state: 'active', + metadata: JSON.stringify( + type === OVERRIDABLE + ? { + name: NAME, label: `${organization_id ?? 'env'} ${type}`, + object: 'task', viewKind: 'list', + columns: [{ field: 'name', label: 'Name' }], + } + : { + name: NAME, label: `${organization_id ?? 'env'} ${type}`, + sharingModel: 'private', + fields: { title: { type: 'text', label: 'Title' } }, + }, + ), +}); + +function matchesWhere(r: Record, where: Record): boolean { + for (const [k, v] of Object.entries(where)) { + // ⛔ REFUSE a combinator rather than reading it as a field name: a + // double that answered `$and` by looking for a column literally called + // `$and` would return a well-formed WRONG answer. + if (k.startsWith('$')) { + throw new Error(`stub engine: unsupported WHERE combinator \`${k}\``); + } + if (v === undefined) continue; + if (r[k] !== v) return false; + } + return true; +} + +/** + * ⛔ Read-only double, deliberately — every case below is a GET, so declaring + * `insert` / `update` / `delete` would owe `check:engine-double-contract` a + * dispatch contract that no case exercises. + */ +function makeStubEngine(rows: Row[]) { + return { + async findOne(table: string, opts: { where: Record }) { + assertEngineFindOnePredicate(table, opts); + if (table !== 'sys_metadata') return null; + return rows.find( + (r) => matchesWhere(r as unknown as Record, opts.where), + ) ?? null; + }, + async find(table: string, opts?: { where?: Record; limit?: number }) { + if (table !== 'sys_metadata') return []; + const matched = rows.filter( + (r) => matchesWhere(r as unknown as Record, opts?.where ?? {}), + ); + // Bound AFTER the filter and BY PRESENCE, so `limit: 0` returns + // nothing rather than everything (`check:objectql-double-limit`). + return typeof opts?.limit === 'number' ? matched.slice(0, opts.limit) : matched; + }, + registry: { + registerItem: () => {}, registerObject: () => {}, + listItems: () => [], getItem: () => undefined, + getObject: () => undefined, getPackage: () => undefined, + getArtifactItem: () => undefined, + isPackageDisabled: () => false, + applyNavContributions: (app: unknown) => app, + }, + } as any; +} + +function mockServer() { + const noop = () => {}; + return { + get: noop, post: noop, put: noop, delete: noop, patch: noop, use: noop, + listen: async () => undefined, close: async () => undefined, + }; +} + +/** + * Records headers — the sibling harness discards them, and the header IS the + * observation channel here. + */ +function mockRes() { + const headers = new Map(); + const res: any = { + statusCode: 200, + _body: undefined, + _headers: headers, + json(body: any) { this._body = body; return this; }, + send() { return this; }, + setHeader(k: string, v: string) { headers.set(String(k).toLowerCase(), String(v)); return this; }, + status(code: number) { this.statusCode = code; return this; }, + header(k: string, v: string) { headers.set(String(k).toLowerCase(), String(v)); return this; }, + }; + return res; +} + +function boot(rows: Row[]) { + const protocol = new ObjectStackProtocolImplementation(makeStubEngine(rows), () => 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 get = async (type: string) => { + const found = (rest as any).getRoutes().find( + (r: any) => r.method === 'GET' && r.path === `${META}/:type/:name`, + ); + if (!found) throw new Error('route not registered: GET /meta/:type/:name'); + const res = mockRes(); + let thrown: any; + try { + await found.handler( + { method: 'GET', path: '', params: { type, name: NAME }, query: {}, headers: {}, body: {} } as any, + res, + ); + } catch (err) { thrown = err; } + return { status: res.statusCode, body: res._body, etag: res._headers.get('etag'), thrown }; + }; + + return { + protocol, + as(tenantId: string | undefined) { + session = tenantId === undefined + ? { userId: 'u1', systemPermissions: ['manage_metadata'] } + : { userId: 'u1', systemPermissions: ['manage_metadata'], tenantId }; + }, + get, + }; +} + +/** The document a GET served, whichever envelope shape the arm answers in. */ +const servedLabel = (body: any): string | undefined => + (body?.item ?? body?.data ?? body)?.label; + +// ═══════════════════════════════════════════════════════════════════════════ +// §0 — the channel itself, proved before anything is concluded from it +// ═══════════════════════════════════════════════════════════════════════════ + +describe('§0 the cached arm runs and emits a validator', () => { + it('a read of the cached-arm type answers 200 with an ETag header', async () => { + const b = boot([row(OVERRIDABLE)]); + const res = await b.get(OVERRIDABLE); + expect(res.thrown, `GET threw: ${res.thrown?.code ?? res.thrown?.message}`).toBeUndefined(); + expect(res.status).toBe(200); + expect(res.etag, 'no ETag header — the cached arm was not taken').toBeTruthy(); + expect(servedLabel(res.body)).toBe(`env ${OVERRIDABLE}`); + }); + + it('the non-overridable type reaches the same arm', async () => { + const b = boot([row(NON_OVERRIDABLE)]); + const res = await b.get(NON_OVERRIDABLE); + expect(res.thrown, `GET threw: ${res.thrown?.code ?? res.thrown?.message}`).toBeUndefined(); + expect(res.status).toBe(200); + expect(res.etag, 'no ETag header — the cached arm was not taken').toBeTruthy(); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// §1 — the door reduces the organization before the validator is computed +// ═══════════════════════════════════════════════════════════════════════════ + +describe('§1 two tenants reading ONE env-wide document', () => { + let rows: Row[]; + beforeEach(() => { rows = []; }); + + it(`${NON_OVERRIDABLE}: the validators are IDENTICAL — the door gated both organizations away`, async () => { + rows.push(row(NON_OVERRIDABLE)); + const b = boot(rows); + + b.as(ORG_A); + const a = await b.get(NON_OVERRIDABLE); + b.as(ORG_B); + const bb = await b.get(NON_OVERRIDABLE); + b.as(undefined); + const none = await b.get(NON_OVERRIDABLE); + + expect(servedLabel(a.body)).toBe(`env ${NON_OVERRIDABLE}`); + expect(servedLabel(bb.body)).toBe(`env ${NON_OVERRIDABLE}`); + expect(servedLabel(none.body)).toBe(`env ${NON_OVERRIDABLE}`); + + // The claim: the scope the validator states is the scope the body was + // resolved under — environment-wide, for all three callers. + expect( + a.etag, + 'the cached door stopped reducing the organization — see #16525', + ).toBe(none.etag); + expect(bb.etag).toBe(none.etag); + }); + + it(`⭐ CONTROL — ${OVERRIDABLE}: the validators DIFFER, because the door forwards the organization`, async () => { + rows.push(row(OVERRIDABLE)); + const b = boot(rows); + + b.as(ORG_A); + const a = await b.get(OVERRIDABLE); + b.as(ORG_B); + const bb = await b.get(OVERRIDABLE); + b.as(undefined); + const none = await b.get(OVERRIDABLE); + + // Same bytes for all three — the organization resolves no row here, so + // any validator difference is the SCOPE component and nothing else. + expect(servedLabel(a.body)).toBe(`env ${OVERRIDABLE}`); + expect(servedLabel(bb.body)).toBe(`env ${OVERRIDABLE}`); + expect(servedLabel(none.body)).toBe(`env ${OVERRIDABLE}`); + + expect(a.etag, 'the control did not fire — the ETag ignores the organization entirely').not.toBe(none.etag); + expect(bb.etag).not.toBe(none.etag); + expect(a.etag).not.toBe(bb.etag); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// §2 — the organization still reaches the BODY where it legitimately can +// ═══════════════════════════════════════════════════════════════════════════ + +describe('§2 the reduction is the registry gate, not a dropped organization', () => { + it(`${OVERRIDABLE}: an org-scoped row is still served to its tenant`, async () => { + // ⭐ Without this, §1 is equally consistent with a door that forwards no + // organization at all — which would be #9454 reopened, not a fix. + const b = boot([row(OVERRIDABLE), row(OVERRIDABLE, ORG_A)]); + + b.as(ORG_A); + const mine = await b.get(OVERRIDABLE); + b.as(ORG_B); + const theirs = await b.get(OVERRIDABLE); + + expect(servedLabel(mine.body)).toBe(`${ORG_A} ${OVERRIDABLE}`); + expect(servedLabel(theirs.body)).toBe(`env ${OVERRIDABLE}`); + expect(mine.etag).not.toBe(theirs.etag); + }); + + it(`${NON_OVERRIDABLE}: a phantom org row is served to nobody`, async () => { + const b = boot([row(NON_OVERRIDABLE), row(NON_OVERRIDABLE, ORG_A)]); + + b.as(ORG_A); + const mine = await b.get(NON_OVERRIDABLE); + expect(servedLabel(mine.body)).toBe(`env ${NON_OVERRIDABLE}`); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// §3 — ⭐ the counterfactual: what the pre-gate is actually worth +// ═══════════════════════════════════════════════════════════════════════════ + +describe('§3 handing the protocol the RAW tenant issues a different validator', () => { + it(`${NON_OVERRIDABLE}: the raw-tenant validator differs from the one the door issued`, async () => { + const rows = [row(NON_OVERRIDABLE)]; + const b = boot(rows); + + b.as(ORG_A); + const throughDoor = await b.get(NON_OVERRIDABLE); + + // The same read the door performs, minus the door's `organizationIdFor + // MetaRead` reduction. The protocol re-folds for the BODY — the label + // below proves it — and does NOT re-fold for the validator. + const raw = await b.protocol.getMetaItemCached({ + type: NON_OVERRIDABLE, name: NAME, organizationId: ORG_A, + }); + + expect(raw.data?.label, 'the callee stopped re-folding for the body').toBe(`env ${NON_OVERRIDABLE}`); + expect( + throughDoor.etag, + 'the door\'s pre-gate no longer changes the validator — re-read #16525 §3', + ).not.toBe(`"${raw.etag.value}"`); + }); +}); From 672f0fb51a3784c4eda5a00787dd889af7eda15f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 03:18:35 +0000 Subject: [PATCH 3/4] docs(metadata-protocol): state the cached-ETag org scope as it is, not as future The fold's rationale argued from "any FUTURE path that resolves an org row but falls back to the env-wide body". That path is present: `getMetaItem` resolves `(orgId ? findOverlay(orgId) : undefined) ?? findOverlay(null)`, so an organization with no row of its own is served the env-wide document under an org-named validator. Reading it as future is how the next author concludes the risk has not arrived. Also records the two facts a reader of this block needs and could not get from it: `request.organizationId` is the SUPPLIED member (the registry reduction happens below, inside `getMetaItem`), and the reason neither that nor the fallback is a correctness fault is that `content` is inside the same hash -- so a 304 can only ever pin a caller to bytes it received, and the residue is validator fragmentation rather than error. Comment only; no behaviour change. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU --- packages/metadata-protocol/src/protocol.ts | 39 +++++++++++++++++++--- 1 file changed, 35 insertions(+), 4 deletions(-) diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 3d7ca6399e..b8dae77930 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -11916,12 +11916,43 @@ export class ObjectStackProtocolImplementation implements // It is folded in anyway because that makes the scope a DECLARED // property of the validator instead of an emergent property of the // body. Two orgs whose documents are byte-identical today share a - // validator by coincidence, not by statement; and any future path - // that resolves an org row but falls back to the env-wide body - // would answer a 304 pinning the caller to a wrong-scope document - // with nothing in the validator to show it. Prepended, and ONLY + // validator by coincidence, not by statement. Prepended, and ONLY // when present, so an org-less caller's validator stays byte-for- // byte the one it is issued today. + // + // [#16525] ⚠️ The paragraph above used to argue from "any FUTURE + // path that resolves an org row but falls back to the env-wide + // body". THAT PATH IS PRESENT, and reading it as future is how a + // later author concludes the risk has not arrived yet: + // `getMetaItem` resolves `(orgId ? findOverlay(orgId) : undefined) + // ?? findOverlay(null)`, so an organization with no row of its own + // is served the env-wide document under an org-named validator. + // + // ⭐ AND `request.organizationId` IS THE SUPPLIED MEMBER, not the + // effective scope: {@link organizationIdForMetaRead} reduces it to + // `undefined` for a type declaring `allowOrgOverride: false`, and + // that reduction happens BELOW this line, inside `getMetaItem`. So + // a caller that hands this verb a raw organization gets a validator + // naming a scope its body was never resolved under. + // + // ⛔ Neither is a correctness fault, and the reason is the ONE + // invariant this block depends on: `content` — the bytes actually + // being sent — is inside the hash below. A 304 is therefore + // answered only on an exact match over those bytes, so a caller is + // only ever pinned to the representation IT received; the cost is + // validator FRAGMENTATION (N orgs, one env-wide document, N + // validators), which is waste, not error. ⇒ Hashing anything + // cheaper than the document — a version marker, the scope alone — + // destroys that argument silently. `get-meta-item-cached-etag- + // scope.test.ts` §3 is the pin; measured, removing `content` here + // reddens exactly one assertion and leaves the rest green. + // + // ⛔ Do NOT "repair" this by folding the effective value without + // reading #16525: it changes every published ETag that carries an + // organization, and buys nothing at the only production door — + // `@objectstack/rest` computes `organizationIdForMetaRead` BEFORE + // it calls (pinned by `rest-server-meta-cached-etag-door-scope. + // test.ts`), so supplied and effective already agree there. const content = JSON.stringify(item); const scope = [ request.organizationId ? `org:${request.organizationId}` : undefined, From dced2447e511834beee2f5524d8d8ba7cd487908 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 03:48:24 +0000 Subject: [PATCH 4/4] chore(scripts): record the new REST findOne double in the pinned ledger `check:engine-double-contract` named it: the cached-ETag door pin declares a findOne double the pinned ledger does not record, so the ledger would not protect that file. Written by the gate's own `--write`, which reports 0 seams added, grown or lost. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU --- scripts/engine-double-contract.pinned.json | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index d9f65548e6..9eae55ed2f 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -3266,6 +3266,11 @@ "verb": "update", "pinned": 1 }, + { + "file": "packages/rest/src/rest-server-meta-cached-etag-door-scope.test.ts", + "verb": "findOne", + "pinned": 1 + }, { "file": "packages/rest/src/rest-server-meta-history-diff-org-scope.test.ts", "verb": "delete",