Skip to content

Commit cf16a0d

Browse files
fix(objectql)!: expand and the master-detail parent root never hydrate a stored-metadata-body table (#24382)
Fixes #24358 Clause-②: no (narrowing) (owes one `CONTRACT_REVIEW_TIER` review before the queue) ⚠️ **Security card.** This body keeps to the card's disclosure line: category and position only. No request, field path or value is written here. Tests use synthetic placeholders only. ## What changes The stored-metadata-body family (`STORED_METADATA_BODY_OBJECTS`) is projected at its doors, but only for the object a request names. Two relationship hydrations in `packages/objectql` read a family row as an ordinary row. Each now refuses a family target with the one family predicate (`isStoredMetadataBodyObject`), as PR #24344 does at the one-hop loader. - **Exit 1, `expandRelatedRecords`.** A reference into the family is never expanded, at any depth, for any caller. The field keeps its stored id: the answer the door already gives for a reference the caller may not expand (#22661's precedent, and the refused sub-read). - **Exit 2, the master-detail `parent` root.** `resolveMasterDetailParent` and `resolveMasterDetailParents` never read a family header. `parent` stays unbound, which is the fail-closed signal (#4889) an unreadable header already gives, so a field rule that reads it has no verdict, whatever the row holds. The `validate()` preview's own first header read (`resolvePreviewParents`) gets the same guard, so the preview reads no family row either. - **The family's enumeration pin** gains both exits (26 → 28 rows), each naming its owning pin. No second projection. Nothing in `packages/spec`, no new error code, and no edit to the doors' projection or the reader-context seam. `resolveMasterDetailRelation` is unchanged: the refusal sits at the reads, so the relation's other readers keep their answers. ## Reach, measured before the fix Live over HTTP, on `main` `a2a3777bf` (`app-todo`, SQLite, `--fresh`), with a synthetic stored datasource-type row carrying a placeholder credential, and a control row without one. "Served" means a value the family's own door withholds from that same reader: the credential material or the unkeyed hash. For every reader below, the family's own door served 0 such values. **Exit 1 (`expand`).** Rows of the carrier that served a family value. Each cell holds for the list read with both expand spellings, the single-record read and the nested query form. | Reader | Session | API key | | --- | --- | --- | | Administrator | YES 1/1 | YES 1/1 | | Member granted read on the family table | YES 1/1 | YES 1/1 | | Default member (read on the carrier only) | NO 0/1 (stored id) | NO 0/1 (stored id) | - The MCP tools declare no expand parameter (read in code), so the MCP reader is not an entry point. - A hook body's `ctx.api` read reaches the same loop through `find`. It is pinned at the engine, not measured over HTTP. **Exit 2 (the `parent` root).** The verdict for the same write under the header holding the placeholder and under the control header: | Reader | Verdict depends on the row? | | --- | --- | | Administrator | YES: `requiredWhen` 2/2 writes differ; `readonlyWhen` over the stored hash 2/2 differ | | Member granted read on the family table | YES: `requiredWhen` 2/2 differ | | Default member | NO: the same unbound refusal under both headers | Why #24316's dev saw "refused as unbound": the write's first header read is the caller's own read door (#22519, `masterDetailHeadersCallerReads`). For a caller whose door does not return the family header, `parent` stays unbound. For a caller whose door does return it, the second, elevated read binds the whole row. **Producers.** Each door, for a lookup and for a master_detail into a family table: - metadata save door: ACCEPTED 1/1 for each; - `os validate` and `os build`: ACCEPTED, exit 0, for both; - package install over HTTP: its body refuses inline object definitions (400). An installed artifact's objects arriving by another path: NOT MEASURED. - In-repo producers declaring such a reference: 0. As a control, references to an ordinary platform table appear in 26 files. **Exposure.** - Exit 1: the `expand` loop was first published in `@objectstack/objectql` 3.1.0 (bisected over the npm tarballs). The family's door projection was first published in `@objectstack/metadata-protocol` 17.6.0; from that release on, `expand` bypasses it. The loop has no family check in 17.6.0 and 17.7.0 (`latest`), or in `18.0.0-next.3` (`next`). - Exit 2: the header binder is published from `@objectstack/objectql` 17.0.0-rc.4 (a symbol bisect; an earlier spelling was not measured). It is unguarded in the same three releases. - Cloud's framework pin was NOT MEASURED directly: the cloud repository is not reachable from this session. The pin triage read on #24316 (`be076dad28a0`) is on `main`, and its tree carries both exits unguarded beside the door projection. ## After the fix Live, after rebuilding objectql, on a fresh backend: - Exit 1: 0 family values served across all 6 reader and auth cells and all 4 read forms. The field answers with the stored id. - Exit 2: the same refusal under both headers, for all 3 readers. - Both save doors still accept the field. That is the route decision below. ## Route, by the four axes The engine guards were chosen. The author-time refusal (a lint rule refusing a reference or master_detail into a family table at save) was not added here. - **Business need:** 0 in-repo producers declare such a reference, so there is no pull for a new authoring rule. - **Long-term:** the engine is the one place every producer reaches, including an installed artifact that runs no authoring rule. So the guards are owed under either route. - **AI error-proofing:** a loud refusal at save would teach the author sooner. The trade-off is recorded for the seat and the maintainer rather than taken here. - **Startup focus:** a new authoring-rule surface is held back by default. ## Verification (head `b3af029a0`, after merging `origin/main`) - `@objectstack/objectql` test: 443 files, 8337 passed. - `@objectstack/metadata-protocol` test (before the merge, at `61df76467`): 244 passed and 3 skipped files, 20641 passed and 19 skipped. The family pin was re-run after the merge: 9 passed. - New engine pin `engine-hydration-stored-metadata-body.test.ts`: 22 passed, with PR #24344's pin beside it (32 together). - typecheck: objectql exit 0 (its tests are covered by `check:test-typecheck`) and metadata-protocol exit 0 (the pin is in its tsc program). - `dispatch-gates --commands`: 70 derived, 70 run, each exit captured before any pipe, all 0. `--ran` printed "70 derived, 70 run, 0 NOT-MEASURED (a DERIVED zero)". - **Ablations** (`scripts/ablation-replace.mjs`; each mutation landed and was restored to the HEAD blob with `git diff HEAD` empty): - expand guard: 8 cases red; - single-id header guard: 4 red (the update path); - batch header guard: 4 red (the insert path); - preview guard: 4 red. Its first attempt was an anchor miss, which the tool refused: nothing was written, and it was re-run with the right anchor. ## Acceptance notes - The preview guard in `resolvePreviewParents` is outside the claim's named functions. It is the third binder of the same `parent` root, in the same file. - A detail whose master is a family table and whose field rule reads `parent` now refuses every write. That is the fail-closed answer a default member already got before this change. - The author-time refusal is left for the seat and the maintainer to weigh. No producer exists today. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01MHGpe6k6Yrmt3TsGu3wgYP --- _Generated by [Claude Code](https://claude.ai/code/session_01MHGpe6k6Yrmt3TsGu3wgYP)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent c76e849 commit cf16a0d

4 files changed

Lines changed: 286 additions & 3 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@objectstack/objectql': minor
3+
---
4+
5+
fix(objectql)!: `expand` and the master-detail `parent` root never hydrate a stored-metadata-body table
6+
7+
Clause-②: no (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) No stored metadata moves and nothing is converted: what narrows is a served value (the expansion of a reference into a stored-metadata-body table) and a verdict (a field rule reading `parent` on a detail whose master is such a table); no spec key, export, option, object definition or stored shape is added, removed, renamed or re-shaped, so there is nothing for `objectstack migrate meta` to rewrite. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); nothing exported is removed (not runtime-export-retired) and no type changes (not type-surface-only or runtime-interface-only). -->
10+
11+
**BREAKING** (a served-set narrowing), shipped as `minor` under the launch-window convention for breaking changes.
12+
13+
The tables of the stored-metadata-body family (`STORED_METADATA_BODY_OBJECTS`) are projected or refused at every door that serves them, but only for the object a request names. Two relationship hydrations in the engine read a family row as an ordinary row, which that projection does not reach.
14+
15+
- **`expand`.** A reference field whose target is a family table is not expanded, at any depth and for any caller: the field keeps its stored id, the answer a reference the caller may not expand already gets. This covers `expand` / `$expand` on the data read, `findOne`, the nested query form and a hook body's `ctx.api` read. Expanding a reference into any other object is unchanged.
16+
- **The master-detail `parent` root.** A detail whose master is a family table never reads its header. `parent` is unbound, so a `requiredWhen` or `readonlyWhen` that reads it fails closed exactly as it does for a header the caller cannot read, on insert, update and the `validate()` preview, and the verdict never depends on the row. A master outside the family is unchanged.
17+
18+
What to do. Read stored metadata through the metadata API (`GET /api/v1/meta/:type/:name`), where its body is projected, rather than by expanding a reference to it or reading it as a master-detail header. The stored id on the reference field is unchanged.

‎packages/metadata-protocol/src/stored-metadata-body-family.pin.test.ts‎

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,10 @@
4040
* (`engine-formula-hop-stored-metadata-body.test.ts`: the engine never reads
4141
* a hop into the family), and its author-time refusal → `@objectstack/lint`
4242
* (`validate-expressions.stored-metadata-body-hop.test.ts`);
43+
* - [#24358] the engine's two relationship hydrations outside that loader,
44+
* `expand` and the master-detail `parent` root → `@objectstack/objectql`
45+
* (`engine-hydration-stored-metadata-body.test.ts`: the engine never
46+
* hydrates a reference or a header into the family);
4347
* - the generic data door reads / groupBy / filter+sort → this package
4448
* (`protocol.data-door-stored-metadata-redaction.test.ts`, and the behaviour
4549
* asserted below).
@@ -148,6 +152,20 @@ const FAMILY_SURFACES = [
148152
disposition: 'refuses',
149153
pin: '@objectstack/lint (validate-expressions.stored-metadata-body-hop.test.ts)',
150154
},
155+
// [#24358] The engine's two relationship hydrations outside the one-hop
156+
// loader. `expand` never reads a reference into the family, so the field
157+
// keeps its stored id, and the master-detail `parent` root never reads a
158+
// family header, so a `parent.*` rule has no verdict whatever the row holds.
159+
{
160+
surface: 'expand of a reference into the family: the carrying object\'s data read (expand / $expand, the nested form), findOne, a hook body\'s ctx.api read',
161+
disposition: 'withheld',
162+
pin: '@objectstack/objectql (engine-hydration-stored-metadata-body.test.ts)',
163+
},
164+
{
165+
surface: 'master-detail parent root over a family header: requiredWhen / readonlyWhen on insert and update, the validate() preview',
166+
disposition: 'refuses',
167+
pin: '@objectstack/objectql (engine-hydration-stored-metadata-body.test.ts)',
168+
},
151169
] as const;
152170

153171
/** A stored datasource body as it sits in the `metadata` column: serialized JSON with credential material. */
@@ -219,8 +237,9 @@ describe('[#21120] stored-metadata-body family — the data door exposes only co
219237
// (audit, analytics, realtime, and the MCP stdio reader's seam and refusal),
220238
// + [#21207] the thirteen surfaces of the stored content hash, and the two
221239
// of the history change note that can quote one, + [#24316] the three of
222-
// the one-hop read through a reference.
223-
expect(FAMILY_SURFACES).toHaveLength(26);
240+
// the one-hop read through a reference, + [#24358] the two relationship
241+
// hydrations outside it.
242+
expect(FAMILY_SURFACES).toHaveLength(28);
224243
});
225244
});
226245

Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#24358] The engine's two relationship hydrations outside the one-hop loader
5+
* never read the stored-metadata-body family (`STORED_METADATA_BODY_OBJECTS`):
6+
*
7+
* - `expand` (`expandRelatedRecords`) — the REST read of a carrying object
8+
* with `expand` / `$expand`, its `findOne`, the nested AST form, and a hook
9+
* body's `ctx.api` read, which all reach the same loop through `find` /
10+
* `findOne`;
11+
* - the master-detail `parent` root (`resolveMasterDetailParent` /
12+
* `resolveMasterDetailParents`) — a field rule's `requiredWhen` /
13+
* `readonlyWhen` on insert and update, and the `validate()` preview, which
14+
* binds through the same batch read.
15+
*
16+
* The family's projection runs at its doors, and only for the object a request
17+
* names, so either hydration would hand the caller (or a `parent.*` verdict)
18+
* the stored body and its content hash unprojected. Each case below asserts
19+
* that no family value is served, that no family read asks for more than the
20+
* `id` column, and that the answer is the one a reference the caller may not
21+
* hydrate already gets: the stored id for `expand`, an unbound `parent` for a
22+
* rule, identical whatever the header holds. A hydration into an ordinary
23+
* object, on the same objects, is still served.
24+
*/
25+
import { beforeEach, describe, expect, it } from 'vitest';
26+
import { STORED_METADATA_BODY_OBJECTS } from '@objectstack/spec/kernel';
27+
import { ObjectQL } from './engine.js';
28+
29+
type Rec = Record<string, unknown>;
30+
31+
const silentLogger: any = {
32+
trace() {}, debug() {}, info() {}, warn() {}, error() {}, fatal() {},
33+
child() { return silentLogger; },
34+
};
35+
36+
function makeDriver() {
37+
const stores = new Map<string, Map<string, Rec>>();
38+
const storeFor = (o: string) => {
39+
let s = stores.get(o);
40+
if (!s) { s = new Map(); stores.set(o, s); }
41+
return s;
42+
};
43+
const matches = (row: Rec, where: any): boolean => {
44+
if (!where || typeof where !== 'object') return true;
45+
return Object.entries(where).every(([k, v]: [string, any]) => {
46+
if (k === '$and') return (v as any[]).every((w) => matches(row, w));
47+
if (k === '$or') return (v as any[]).some((w) => matches(row, w));
48+
if (v && typeof v === 'object' && !Array.isArray(v)) {
49+
if ('$in' in v) return Array.isArray(v.$in) && v.$in.includes(row?.[k]);
50+
if ('$eq' in v) return row?.[k] === v.$eq;
51+
}
52+
return row?.[k] === v;
53+
});
54+
};
55+
const project = (r: Rec, fields: string[] | undefined): Rec => {
56+
if (!fields) return { ...r };
57+
const out: Rec = {};
58+
for (const f of fields) if (r[f] !== undefined) out[f] = r[f];
59+
return out;
60+
};
61+
const calls: Array<{ object: string; ast: any }> = [];
62+
let n = 0;
63+
const driver: any = {
64+
name: 'memory', version: '0.0.0', supports: {},
65+
async connect() {}, async disconnect() {}, async checkHealth() { return true; }, async execute() { return null; },
66+
async find(object: string, ast: any) {
67+
calls.push({ object, ast });
68+
const rows = Array.from(storeFor(object).values()).filter((r) => matches(r, ast?.where));
69+
const offset = typeof ast?.offset === 'number' ? ast.offset : 0;
70+
const paged = typeof ast?.limit === 'number' ? rows.slice(offset, offset + ast.limit) : rows.slice(offset);
71+
return paged.map((r) => project(r, ast?.fields));
72+
},
73+
async findOne(object: string, ast: any) {
74+
calls.push({ object, ast });
75+
for (const r of storeFor(object).values()) if (matches(r, ast?.where)) return project(r, ast?.fields);
76+
return null;
77+
},
78+
async create(object: string, data: Rec) {
79+
n += 1;
80+
const id = (data.id as string) ?? `r_${n}`;
81+
const row = { ...data, id };
82+
storeFor(object).set(id, row);
83+
return { ...row };
84+
},
85+
async update(object: string, id: string, data: Rec) {
86+
const s = storeFor(object);
87+
const row = { ...s.get(id), ...data, id };
88+
s.set(id, row);
89+
return { ...row };
90+
},
91+
async updateMany() { return 0; },
92+
async delete(object: string, id: string) { return storeFor(object).delete(id); },
93+
async count() { return 0; },
94+
async bulkCreate(object: string, rows: Rec[]) { return Promise.all(rows.map((r) => this.create(object, r))); },
95+
async bulkUpdate() { return []; }, async bulkDelete() {},
96+
async beginTransaction() { return { __trx: true, commit: async () => {}, rollback: async () => {} }; },
97+
async commit() {}, async rollback() {},
98+
};
99+
return { driver, storeFor, calls };
100+
}
101+
102+
/** A synthetic stored datasource body with placeholder credential material, and a control body without it. */
103+
const SECRET = 'PLACEHOLDER-SECRET';
104+
const STORED_BODY = JSON.stringify({ name: 'ds', driver: 'turso', config: { url: 'libsql://x.invalid', encryptionKey: SECRET } });
105+
const CONTROL_BODY = JSON.stringify({ name: 'ds2', driver: 'turso', config: { url: 'libsql://y.invalid' } });
106+
const MEMBER = { userId: 'u1', tenantId: 'org_1', positions: ['member'] } as any;
107+
const SYSTEM = { isSystem: true } as any;
108+
const servesNothing = (value: unknown) => {
109+
const text = JSON.stringify(value) ?? '';
110+
expect(text).not.toContain(SECRET);
111+
expect(text).not.toContain('PLACEHOLDER-HASH');
112+
};
113+
const failure = (p: Promise<unknown>) => p.then(() => undefined, (e: any) => ({ code: e?.code, message: String(e?.message) }));
114+
115+
for (const family of STORED_METADATA_BODY_OBJECTS) {
116+
describe(`[#24358] '${family}' is never hydrated`, () => {
117+
let engine: ObjectQL;
118+
let d: ReturnType<typeof makeDriver>;
119+
/** Every family read, and whether one asked for anything beyond the `id` column. */
120+
const familyReads = () => d.calls.filter((c) => c.object === family);
121+
const familyBodyReads = () => familyReads().filter((c) => !(Array.isArray(c.ast?.fields) && c.ast.fields.length === 1 && c.ast.fields[0] === 'id'));
122+
123+
beforeEach(async () => {
124+
engine = new ObjectQL({ logger: silentLogger });
125+
d = makeDriver();
126+
engine.registerDriver(d.driver, true);
127+
await engine.init();
128+
engine.registry.registerObject({
129+
name: family,
130+
fields: { type: { type: 'text' }, name: { type: 'text' }, metadata: { type: 'textarea' }, checksum: { type: 'text' } },
131+
} as any, 'test-package');
132+
engine.registry.registerObject({
133+
name: 'probe_other',
134+
fields: { name: { type: 'text' }, meta: { type: 'lookup', reference: family } },
135+
} as any, 'test-package');
136+
engine.registry.registerObject({
137+
name: 'probe_carrier',
138+
fields: {
139+
meta: { type: 'lookup', reference: family },
140+
metas: { type: 'lookup', reference: family, multiple: true },
141+
other: { type: 'lookup', reference: 'probe_other' },
142+
},
143+
} as any, 'test-package');
144+
engine.registry.registerObject({
145+
name: 'probe_detail',
146+
fields: {
147+
hdr: { type: 'master_detail', reference: family },
148+
note: { type: 'text', requiredWhen: `parent.metadata.contains('${SECRET}')` },
149+
note2: { type: 'text', readonlyWhen: "parent.checksum.startsWith('sha256:')" },
150+
},
151+
} as any, 'test-package');
152+
engine.registry.registerObject({
153+
name: 'probe_ordinary_detail',
154+
fields: {
155+
hdr: { type: 'master_detail', reference: 'probe_other' },
156+
note: { type: 'text', requiredWhen: "parent.name == 'Other'" },
157+
},
158+
} as any, 'test-package');
159+
d.storeFor(family).set('m1', { id: 'm1', type: 'datasource', name: 'ds', metadata: STORED_BODY, checksum: 'sha256:PLACEHOLDER-HASH' });
160+
d.storeFor(family).set('m2', { id: 'm2', type: 'datasource', name: 'ds2', metadata: CONTROL_BODY, checksum: 'md5:PLACEHOLDER-HASH' });
161+
d.storeFor('probe_other').set('o1', { id: 'o1', name: 'Other', meta: 'm1' });
162+
d.storeFor('probe_carrier').set('c1', { id: 'c1', meta: 'm1', metas: ['m1', 'm2'], other: 'o1' });
163+
d.storeFor('probe_detail').set('dt1', { id: 'dt1', hdr: 'm1', note: 'n' });
164+
d.storeFor('probe_detail').set('dt2', { id: 'dt2', hdr: 'm2', note: 'n' });
165+
d.calls.length = 0;
166+
});
167+
168+
for (const [who, context] of [['a member', MEMBER], ['SYSTEM', SYSTEM]] as const) {
169+
it(`expand as ${who}: a reference into the family keeps its stored id, single and multiple, and is never read`, async () => {
170+
const rows = await engine.find('probe_carrier', { expand: { meta: {}, metas: {}, other: {} } as any, context });
171+
expect(rows).toHaveLength(1);
172+
expect(rows[0]).toMatchObject({ meta: 'm1', metas: ['m1', 'm2'], other: { id: 'o1', name: 'Other' } });
173+
servesNothing(rows);
174+
expect(familyReads()).toHaveLength(0);
175+
});
176+
177+
it(`expand as ${who}: a nested expansion into the family, and findOne, keep the stored id`, async () => {
178+
const nested = await engine.find('probe_carrier', { expand: { other: { expand: { meta: {} } } } as any, context });
179+
expect(nested[0]).toMatchObject({ other: { id: 'o1', name: 'Other', meta: 'm1' } });
180+
const one = await engine.findOne('probe_carrier', { where: { id: 'c1' }, expand: { meta: { fields: ['metadata', 'checksum'] } } as any, context });
181+
expect(one).toMatchObject({ meta: 'm1' });
182+
servesNothing([nested, one]);
183+
expect(familyReads()).toHaveLength(0);
184+
});
185+
186+
it(`parent as ${who}: a requiredWhen over a family header has no verdict, the same refusal whatever the header holds`, async () => {
187+
const holding = await failure(engine.insert('probe_detail', { hdr: 'm1' }, { context }));
188+
const control = await failure(engine.insert('probe_detail', { hdr: 'm2' }, { context }));
189+
expect(holding).toMatchObject({ code: 'VALIDATION_FAILED' });
190+
expect(holding?.message).toContain("reads 'parent'");
191+
// The verdict does not depend on the row: the bit the body would decide is not served.
192+
expect(control).toEqual(holding);
193+
servesNothing([holding, control]);
194+
expect(familyBodyReads()).toHaveLength(0);
195+
});
196+
197+
it(`parent as ${who}: a readonlyWhen over a family header answers the same whatever the header holds`, async () => {
198+
const answers: unknown[] = [];
199+
for (const id of ['dt1', 'dt2']) {
200+
answers.push(await engine.update('probe_detail', { id, note: 'n', note2: 'x' }, { context } as any)
201+
.then((r: any) => ({ note2: r?.note2 ?? null }), (e: any) => ({ code: e?.code })));
202+
}
203+
expect(answers[1]).toEqual(answers[0]);
204+
servesNothing(answers);
205+
expect(familyBodyReads()).toHaveLength(0);
206+
});
207+
208+
it(`parent as ${who}: the validate() preview judges a family header the same whatever it holds`, async () => {
209+
const holding = await engine.validate('probe_detail', { hdr: 'm1' }, { context });
210+
const control = await engine.validate('probe_detail', { hdr: 'm2' }, { context });
211+
expect(JSON.stringify(control)).toBe(JSON.stringify(holding).replaceAll('m1', 'm2'));
212+
servesNothing([holding, control]);
213+
expect(familyBodyReads()).toHaveLength(0);
214+
});
215+
}
216+
217+
it('an ordinary master still binds parent: its requiredWhen is judged on the header', async () => {
218+
const err = await failure(engine.insert('probe_ordinary_detail', { hdr: 'o1' }, { context: SYSTEM }));
219+
expect(err).toMatchObject({ code: 'VALIDATION_FAILED' });
220+
expect(err?.message).not.toContain("reads 'parent'");
221+
await expect(engine.insert('probe_ordinary_detail', { hdr: 'o1', note: 'n' }, { context: SYSTEM })).resolves.toMatchObject({ hdr: 'o1' });
222+
});
223+
});
224+
}

‎packages/objectql/src/engine.ts‎

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9767,6 +9767,14 @@ export class ObjectQL implements IObjectQLEngine {
97679767
): Promise<Record<string, unknown> | null> {
97689768
const rel = resolveMasterDetailRelation(schema);
97699769
if (!rel) return null;
9770+
// [#24358] ⛔ A header in the stored-metadata-body family is never read,
9771+
// under either authority. Read 2 below is a whole-row read the family's
9772+
// projection never reaches, so a `parent.*` predicate would be judged over
9773+
// the stored body and its content hash unprojected: one bit of material the
9774+
// family's own door withholds, per write. `parent` stays unbound, the
9775+
// fail-closed signal (#4889) an unreadable header already gives, so the
9776+
// verdict says nothing about the row.
9777+
if (isStoredMetadataBodyObject(rel.master)) return null;
97709778
const parentId = masterIdOf(rel.fk, data, priorRow);
97719779
if (parentId == null) return null;
97729780
// [#22519] 1. The caller's read door: a header it does not return binds as
@@ -9824,6 +9832,9 @@ export class ObjectQL implements IObjectQLEngine {
98249832
const unbound = () => null;
98259833
const rel = resolveMasterDetailRelation(schema);
98269834
if (!rel) return unbound;
9835+
// [#24358] ⛔ The single-id twin's family guard, for the batch: a
9836+
// stored-metadata-body header is never read, and every row binds unbound.
9837+
if (isStoredMetadataBodyObject(rel.master)) return unbound;
98279838
const ids = new Set<string>();
98289839
for (const row of priorRows ?? []) {
98299840
const id = masterIdOf(rel.fk, data, row);
@@ -9919,7 +9930,9 @@ export class ObjectQL implements IObjectQLEngine {
99199930
priorIds.forEach((id, i) => { if (repoints[i]) ids.add(String(id)); });
99209931

99219932
const headers = new Map<string, Record<string, unknown>>();
9922-
if (rel && ids.size > 0) {
9933+
// [#24358] ⛔ A stored-metadata-body header is never read here either, as
9934+
// the write's own header read never reads one: every header binds unbound.
9935+
if (rel && ids.size > 0 && !isStoredMetadataBodyObject(rel.master)) {
99239936
// 1. The read door, under the caller's own context.
99249937
const served = new Map<string, Record<string, unknown>>();
99259938
try {
@@ -12977,6 +12990,15 @@ export class ObjectQL implements IObjectQLEngine {
1297712990
if (!REFERENCE_VALUE_TYPES.has(fieldDef.type)) continue;
1297812991
const referenceObject = referenceTargetOf(fieldDef);
1297912992
if (!referenceObject) continue;
12993+
// [#24358] ⛔ A reference into the stored-metadata-body family is never
12994+
// expanded, at any depth and for any caller. The family's projection runs
12995+
// at its doors and only for the object a request names, so this sub-read
12996+
// would nest the stored body and its content hash unprojected under the
12997+
// requested rows — material the family's own door withholds from the same
12998+
// reader. The field keeps the stored id, the answer a reference the
12999+
// caller may not expand gets (an unexposed target, #22661, or a refused
13000+
// sub-read, below), so it says nothing about the row.
13001+
if (isStoredMetadataBodyObject(referenceObject)) continue;
1298013002

1298113003
// Collect all foreign key IDs from records (handle both single and multiple values)
1298213004
const allIds: any[] = [];

0 commit comments

Comments
 (0)