Skip to content

Commit 386de16

Browse files
claude[bot]claude
andauthored
test(metadata-protocol,rest): the cached /meta ETag folds the SUPPLIED organization — measured, pinned, not a fault (#17292)
* test(metadata-protocol): pin which organization value the cached ETag folds `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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU * 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU * 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU * 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent b90aff8 commit 386de16

4 files changed

Lines changed: 726 additions & 4 deletions

File tree

Lines changed: 337 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,337 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#16525] WHICH organization value `getMetaItemCached` folds into the ETag —
5+
* the SUPPLIED request member, or the EFFECTIVE scope the registry read gate
6+
* returns — and why the answer is not the defect the card feared.
7+
*
8+
* ── The question, and the measurement ─────────────────────────────────────
9+
*
10+
* The card put two facts side by side. `organizationIdForMetaRead(type, org)`
11+
* answers `undefined` for a supplied organization whenever the registry
12+
* declares `allowOrgOverride: false`, and `getMetaItem` then resolves
13+
* `(orgId ? findOverlay(orgId) : undefined) ?? findOverlay(null)` — so a
14+
* gated-away organization is served the env-wide record. `getMetaItemCached`
15+
* delegates to that. ⇒ If the ETag names the SUPPLIED organization while the
16+
* body is the env-wide representation, the validator states a scope that was
17+
* never served.
18+
*
19+
* MEASURED, and the answer is the SUPPLIED member:
20+
*
21+
* const scope = [
22+
* request.organizationId ? `org:${request.organizationId}` : undefined,
23+
* request.locale || undefined,
24+
* ].filter(...);
25+
*
26+
* §1 reproduces the divergence end to end — `object` is `allowOrgOverride:
27+
* false`, a supplied organization is gated away, the env-wide record is
28+
* served, and the validator still names the organization.
29+
*
30+
* ── ⭐ Why that is a COST and not a correctness fault ──────────────────────
31+
*
32+
* Because `content` — the serialized document actually being sent — is inside
33+
* the same hash. The validator is therefore a function of the bytes, not only
34+
* of the declared scope, so the harm the card names ("a validator claiming to
35+
* describe an org-scoped representation that was never served") cannot reach
36+
* a caller: a `304` is answered only on an exact match of a hash that covers
37+
* those bytes, so a caller is only ever pinned to the representation IT
38+
* previously received. §3 pins that, and it is the half a future change is
39+
* most likely to break silently — hashing a cheap version marker instead of
40+
* the document would leave §1 and §2 green and destroy the whole argument.
41+
*
42+
* What is left is validator FRAGMENTATION: N organizations reading one
43+
* env-wide document through a non-overridable type hold N validators for
44+
* byte-identical content. Wasteful, not wrong.
45+
*
46+
* ── ⚠️ §1 is a CHARACTERIZATION pin, not a prohibition ────────────────────
47+
*
48+
* Folding the EFFECTIVE value instead would merge those validators and make
49+
* the declared scope true. It is deliberately NOT done here: it changes every
50+
* published ETag that carries an organization (one forced miss per caller per
51+
* deploy) and buys nothing at the only production door, which already supplies
52+
* the effective value (pinned in `@objectstack/rest`,
53+
* `rest-server-meta-cached-etag-door-scope.test.ts`). ⇒ If §1 reddens, read
54+
* #16525 before making it green: it means the fold moved, which is a decision,
55+
* not a regression.
56+
*
57+
* ── Why the observation channel is a BODY-vs-VALIDATOR pair ───────────────
58+
*
59+
* Asserting an ETag literal would pin the hash function, which is not what
60+
* this card is about. Every assertion below compares two reads that differ in
61+
* exactly one input, and asserts the served BODIES alongside the validators —
62+
* so an ETag difference can never be attributed to a body difference nobody
63+
* checked, and a body difference can never hide behind a validator nobody
64+
* read.
65+
*/
66+
67+
import { describe, expect, it } from 'vitest';
68+
import { organizationIdForMetaRead } from '@objectstack/metadata-core';
69+
import { DEFAULT_METADATA_TYPE_REGISTRY } from '@objectstack/spec/kernel';
70+
import { ObjectStackProtocolImplementation } from './protocol.js';
71+
72+
const ORG = 'org_acme';
73+
const OTHER_ORG = 'org_globex';
74+
75+
/** `allowOrgOverride: false` — a supplied organization is gated away. */
76+
const NON_OVERRIDABLE = 'object';
77+
78+
/** `allowOrgOverride: true` — a supplied organization survives the gate. */
79+
const OVERRIDABLE = 'view';
80+
81+
interface StoredRow {
82+
id: string;
83+
type: string;
84+
name: string;
85+
organization_id: string | null;
86+
package_id: string | null;
87+
state: string;
88+
metadata: string;
89+
}
90+
91+
/**
92+
* A row whose `label` names the partition it came from, so a served document
93+
* says which row answered without the assertion having to guess.
94+
*/
95+
const storedRow = (
96+
type: string,
97+
name: string,
98+
extra: Partial<StoredRow> = {},
99+
): StoredRow => ({
100+
id: `r_${type}_${name}_${extra.organization_id ?? 'env'}`,
101+
type,
102+
name,
103+
organization_id: null,
104+
package_id: null,
105+
state: 'active',
106+
metadata: JSON.stringify({ name, label: `${extra.organization_id ?? 'env'} ${name}` }),
107+
...extra,
108+
});
109+
110+
/**
111+
* The engine double: `findOne` over a row table, plus the registry surface the
112+
* single-item read path touches on its way past the overlay.
113+
*
114+
* ⛔ No `find` / `insert` / `update` / `delete`, deliberately — the read path
115+
* under test issues exactly one verb, and a double declaring verbs no case
116+
* exercises would owe `check:engine-double-contract` a dispatch contract that
117+
* protects nothing. Same shape the sibling org-read-gate pin drives.
118+
*/
119+
function makeHarness(rows: StoredRow[]) {
120+
const engine: any = {
121+
async findOne(table: string, opts?: { where?: Record<string, unknown> }) {
122+
if (table !== 'sys_metadata') return undefined;
123+
const where = opts?.where ?? {};
124+
// `check:where-matcher` — a hand-written matcher with no combinator
125+
// branch reads `$and` as a field name and answers the wrong
126+
// question rather than failing. Refuse the shape this double does
127+
// not implement, matching the sibling doubles' convention.
128+
for (const k of Object.keys(where)) {
129+
if (k.startsWith('$')) {
130+
throw new Error(`[test double] unsupported WHERE combinator '${k}'`);
131+
}
132+
}
133+
return rows.find((r) =>
134+
Object.entries(where).every(([k, v]) => {
135+
if (v === undefined) return true;
136+
return (r as unknown as Record<string, unknown>)[k] === v;
137+
}),
138+
);
139+
},
140+
registry: {
141+
registerItem: () => undefined,
142+
registerObject: () => undefined,
143+
listItems: () => [],
144+
getItem: () => undefined,
145+
getObject: () => undefined,
146+
getPackage: () => undefined,
147+
getArtifactItem: () => undefined,
148+
isPackageDisabled: () => false,
149+
applyNavContributions: (app: unknown) => app,
150+
},
151+
};
152+
return new ObjectStackProtocolImplementation(engine, () => new Map()) as any;
153+
}
154+
155+
/** The label the served document carries — `'env …'` or `'org_acme …'`. */
156+
const servedLabel = (res: any): string => res?.data?.label;
157+
158+
// ═══════════════════════════════════════════════════════════════════════════
159+
// §0 — the registry facts every section below rests on, read not assumed
160+
// ═══════════════════════════════════════════════════════════════════════════
161+
162+
describe('§0 the fixture types really are on opposite sides of the read gate', () => {
163+
it(`${NON_OVERRIDABLE} is declared allowOrgOverride: false`, () => {
164+
const entry = DEFAULT_METADATA_TYPE_REGISTRY.find((e) => e.type === NON_OVERRIDABLE);
165+
expect(entry, `no registry entry for '${NON_OVERRIDABLE}'`).toBeDefined();
166+
expect(entry!.allowOrgOverride).toBe(false);
167+
expect(organizationIdForMetaRead(NON_OVERRIDABLE, ORG)).toBeUndefined();
168+
});
169+
170+
it(`${OVERRIDABLE} is declared allowOrgOverride: true`, () => {
171+
const entry = DEFAULT_METADATA_TYPE_REGISTRY.find((e) => e.type === OVERRIDABLE);
172+
expect(entry, `no registry entry for '${OVERRIDABLE}'`).toBeDefined();
173+
expect(entry!.allowOrgOverride).toBe(true);
174+
expect(organizationIdForMetaRead(OVERRIDABLE, ORG)).toBe(ORG);
175+
});
176+
});
177+
178+
// ═══════════════════════════════════════════════════════════════════════════
179+
// §1 — (a) WHICH VALUE. The ETag folds the SUPPLIED member.
180+
// ⚠️ Characterization. If this reddens, read #16525 before greening it.
181+
// ═══════════════════════════════════════════════════════════════════════════
182+
183+
describe('§1 the ETag names the SUPPLIED organization, not the effective scope', () => {
184+
it('a gated-away organization is absent from the BODY and present in the VALIDATOR', async () => {
185+
// A pre-#6190 phantom org row sits beside the env-wide one. The gate is
186+
// what keeps it out of the response; without it this case would prove
187+
// nothing about the ETag, because the two reads would differ in body.
188+
const protocol = makeHarness([
189+
storedRow(NON_OVERRIDABLE, 'customer'),
190+
storedRow(NON_OVERRIDABLE, 'customer', { organization_id: ORG, id: 'phantom' }),
191+
]);
192+
193+
const supplied = await protocol.getMetaItemCached({
194+
type: NON_OVERRIDABLE, name: 'customer', organizationId: ORG,
195+
});
196+
const orgless = await protocol.getMetaItemCached({
197+
type: NON_OVERRIDABLE, name: 'customer',
198+
});
199+
200+
// ⭐ The control that makes the validator assertion mean anything: the
201+
// gate held, so BOTH reads were answered by the env-wide row and the
202+
// phantom was not served.
203+
expect(servedLabel(supplied), 'the phantom org row was served').toBe('env customer');
204+
expect(servedLabel(orgless)).toBe('env customer');
205+
expect(JSON.stringify(supplied.data)).toBe(JSON.stringify(orgless.data));
206+
207+
// ⇒ Byte-identical representations, different validators. The scope
208+
// component is the request member, unreduced by the read gate.
209+
expect(
210+
supplied.etag.value,
211+
'the ETag no longer distinguishes a gated-away organization — see #16525',
212+
).not.toBe(orgless.etag.value);
213+
});
214+
215+
it('two organizations gated away from the same document hold two validators', async () => {
216+
const protocol = makeHarness([storedRow(NON_OVERRIDABLE, 'customer')]);
217+
218+
const a = await protocol.getMetaItemCached({
219+
type: NON_OVERRIDABLE, name: 'customer', organizationId: ORG,
220+
});
221+
const b = await protocol.getMetaItemCached({
222+
type: NON_OVERRIDABLE, name: 'customer', organizationId: OTHER_ORG,
223+
});
224+
225+
expect(JSON.stringify(a.data)).toBe(JSON.stringify(b.data));
226+
// The fragmentation the card names. Wasteful, and harmless for the
227+
// reason §3 pins.
228+
expect(a.etag.value).not.toBe(b.etag.value);
229+
});
230+
});
231+
232+
// ═══════════════════════════════════════════════════════════════════════════
233+
// §2 — (b) REACHABILITY of the fallback the fold was written against
234+
// ═══════════════════════════════════════════════════════════════════════════
235+
236+
describe('§2 the org-resolved-then-env-wide fallback is present, not future', () => {
237+
/**
238+
* The fold's own comment argued from "any FUTURE path that resolves an org
239+
* row but falls back to the env-wide body". That path is the `??` in
240+
* `getMetaItem` and it runs today on an OVERRIDABLE type whose
241+
* organization simply has no row — reachable through the production
242+
* cached door, which forwards the organization for exactly these types.
243+
*/
244+
it('an overridable type with no org row serves env-wide under an org-named validator', async () => {
245+
const protocol = makeHarness([storedRow(OVERRIDABLE, 'account_list')]);
246+
247+
const supplied = await protocol.getMetaItemCached({
248+
type: OVERRIDABLE, name: 'account_list', organizationId: ORG,
249+
});
250+
const orgless = await protocol.getMetaItemCached({
251+
type: OVERRIDABLE, name: 'account_list',
252+
});
253+
254+
// The organization survived the gate here — and still resolved nothing,
255+
// so the env-wide row answered.
256+
expect(organizationIdForMetaRead(OVERRIDABLE, ORG)).toBe(ORG);
257+
expect(servedLabel(supplied)).toBe('env account_list');
258+
expect(JSON.stringify(supplied.data)).toBe(JSON.stringify(orgless.data));
259+
expect(supplied.etag.value).not.toBe(orgless.etag.value);
260+
});
261+
262+
it('the same type WITH an org row serves it, so the fallback above was a real fallback', async () => {
263+
// ⭐ Without this control the case above is indistinguishable from a
264+
// harness that can never resolve an org row at all.
265+
const protocol = makeHarness([
266+
storedRow(OVERRIDABLE, 'account_list'),
267+
storedRow(OVERRIDABLE, 'account_list', { organization_id: ORG, id: 'org_row' }),
268+
]);
269+
270+
const supplied = await protocol.getMetaItemCached({
271+
type: OVERRIDABLE, name: 'account_list', organizationId: ORG,
272+
});
273+
expect(servedLabel(supplied)).toBe(`${ORG} account_list`);
274+
});
275+
});
276+
277+
// ═══════════════════════════════════════════════════════════════════════════
278+
// §3 — ⭐ WHY §1 IS A COST AND NOT A FAULT: the served bytes are in the hash
279+
// ═══════════════════════════════════════════════════════════════════════════
280+
281+
describe('§3 the validator is a function of the served bytes, not of the scope alone', () => {
282+
it('an unchanged document answers 304 to the validator it issued', async () => {
283+
// ⭐ The control that must FIRE. Every assertion below reads "no 304";
284+
// if the 304 arm were unreachable in this harness they would all pass
285+
// for the wrong reason.
286+
const rows = [storedRow(NON_OVERRIDABLE, 'customer')];
287+
const protocol = makeHarness(rows);
288+
289+
const first = await protocol.getMetaItemCached({
290+
type: NON_OVERRIDABLE, name: 'customer', organizationId: ORG,
291+
});
292+
const again = await protocol.getMetaItemCached({
293+
type: NON_OVERRIDABLE, name: 'customer', organizationId: ORG,
294+
cacheRequest: { ifNoneMatch: `"${first.etag.value}"` },
295+
});
296+
297+
expect(again.notModified, 'the 304 arm is unreachable in this harness').toBe(true);
298+
});
299+
300+
it('a changed document never answers 304, though the scope is unchanged', async () => {
301+
// This is the invariant the whole "harmless" argument rests on. A hash
302+
// over the scope and a cheap version marker — a plausible optimization —
303+
// leaves §1 and §2 green and turns this red.
304+
const rows = [storedRow(NON_OVERRIDABLE, 'customer')];
305+
const protocol = makeHarness(rows);
306+
307+
const first = await protocol.getMetaItemCached({
308+
type: NON_OVERRIDABLE, name: 'customer', organizationId: ORG,
309+
});
310+
311+
rows[0].metadata = JSON.stringify({ name: 'customer', label: 'env customer REVISED' });
312+
313+
const after = await protocol.getMetaItemCached({
314+
type: NON_OVERRIDABLE, name: 'customer', organizationId: ORG,
315+
cacheRequest: { ifNoneMatch: `"${first.etag.value}"` },
316+
});
317+
318+
expect(after.notModified, 'a stale validator was honoured across a body change').toBe(false);
319+
expect(servedLabel(after)).toBe('env customer REVISED');
320+
expect(after.etag.value).not.toBe(first.etag.value);
321+
});
322+
323+
it("one organization's validator is never honoured for another's request", async () => {
324+
const protocol = makeHarness([storedRow(NON_OVERRIDABLE, 'customer')]);
325+
326+
const mine = await protocol.getMetaItemCached({
327+
type: NON_OVERRIDABLE, name: 'customer', organizationId: ORG,
328+
});
329+
const theirs = await protocol.getMetaItemCached({
330+
type: NON_OVERRIDABLE, name: 'customer', organizationId: OTHER_ORG,
331+
cacheRequest: { ifNoneMatch: `"${mine.etag.value}"` },
332+
});
333+
334+
expect(theirs.notModified).toBe(false);
335+
expect(servedLabel(theirs)).toBe('env customer');
336+
});
337+
});

0 commit comments

Comments
 (0)