Skip to content

Commit 69147ab

Browse files
fix(objectql)!: the one-hop read through a reference never reads a stored-metadata-body table; the lint refuses it (#24344)
Fixes #24316 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. It carries no request, field path or value. Tests use synthetic placeholders only. ## What changes - **`@objectstack/objectql` — the shared one-hop loader (`resolveOneHopRelated`).** A hop whose target is a stored-metadata-body table (`isStoredMetadataBodyObject`, reused from `@objectstack/spec/kernel`) is no longer read, under either authority. The binding is `unreadable`, the shape an unreadable related record already gets. A formula field reads `null` with its reason. A validation rule has no verdict, so the write is refused. Neither answer says anything about the row. One guard covers every exit that reaches the loader: a read of the carrying object (REST, and a hook body's `ctx.api` read), `findOne`, a write's response, `serveFormulaField` (the seam `ctx.title()` reads through), and the validation-rule path, which reads its hop as SYSTEM. - **`@objectstack/lint` — the field-formula pass and hydrated validation conditions.** A one-hop read through a reference into a family table is an error, with a prescription (read stored metadata through the metadata API, where the body is projected). `os validate`, `os build` and the object save door run the same pass, so the author learns it at save time instead of from a `null`. - **The family's enumeration pin** (`stored-metadata-body-family.pin.test.ts`) gains three rows: the formula hop's exits, the validation-rule hop, and the author-time refusal. Each row names its owning pin. 23 → 26 rows. - Not done: no second projection, no `packages/spec` edit, no new error code, and no change to the doors' projection or the reader-context seam. ## Reach, measured before the fix (live backend, showcase app, `--fresh`) The setup used a synthetic placeholder credential in a stored datasource body, and a carrier object with a reference into the family plus formulas through it. | Reader | Family's own door | Through the hop (before) | Through the hop (after) | |---|---|---|---| | administrator, session | redacted body, keyed hash | **yes**, 1/1 rows: placeholder and unkeyed hash served | no (engine pin) | | administrator, API key | redacted, keyed | **yes**, 1/1 | no (engine pin) | | member granted read on the family table, session | redacted, keyed | **yes**, 1/1 | no (engine pin) | | same member, API key | redacted, keyed | **yes**, 1/1 | no (engine pin) | | member, no grant on the family table (default), session and API key | refused (`PERMISSION_DENIED`) | no, 0/1 (formulas `null`) | no | - **Validation-rule hop (SYSTEM authority), before:** an administrator received the rule's own verdict over the unprojected body, which is a one-bit oracle per write. A member with no grant got the unevaluable refusal. After: no verdict for any caller (engine pin). - **Master-detail `parent` root:** measured with no verdict served. The write was refused as unbound, so it is out of this change. | Producer door | Before | After | |---|---|---| | metadata save door (object type) | **accepted** (1/1 objects with formula hops; 1/1 with a rule hop) | **refused** at author-time validation, 2/2 objects (each hop named) | | `os validate` / `os build` (`validateStackExpressions`) | accepted (the lint pin goes red when its refusal is ablated) | refused (lint pin) | | package install | NOT MEASURED: no install of a package carrying such an object was driven | — | ## Verification (head `7946ad1a2`, after merging `origin/main`) - `pnpm --filter @objectstack/objectql test`: 440 files / 8290 tests passed. - `pnpm --filter @objectstack/lint test`: 145 / 6864 passed. - `pnpm --filter @objectstack/metadata-protocol test`: 244 passed, 3 skipped / 20641 passed, 19 skipped. - `typecheck` for objectql, lint and metadata-protocol: exit 0 each, re-run after the merge and a rebuild of their dependency closure. - After the merge, the targeted suites were re-run: 35, 443 and 9 tests passed. - Derived gates (`dispatch-gates.mjs --commands`, no paths): 71 commands, all exit 0 on `7946ad1a2`. `--ran` reconciliation: 71 derived, 71 run, 0 NOT-MEASURED (a derived zero). - On the first run, two gates refused because the clone was shallow. After deepening, both passed (exit 0). - **Ablation 1 (engine guard disabled):** ran with `scripts/ablation-replace.mjs`, which confirmed the mutation landed. The engine pin went 10/10 red. The tool restored the file to the HEAD blob, with `git diff HEAD` empty. - **Ablation 2 (lint refusal disabled):** the lint pin went 5 red and 1 green. The green case is the control: a hop into an ordinary object and a bare id read. The file was restored to the HEAD blob. - **Pin sweep:** no other test declares a reference into the family. The family pin's row count is the only pin that changed meaning, and it is load-bearing (23 → 26). - The two existing lint pins that moved are adjusted, not weakened: - the #5017 receiver meta-test excuses the new Map/Set locals; - the master-detail carrier refusal still comes from its own pass, because the new index is read lazily. ## Acceptance notes - The runtime answer reuses the `unreadable` binding, so a formula's fault text reads "that read failed". The authored refusal with its prescription is the lint's, at save. --- _Generated by [Claude Code](https://claude.ai/code/session_01DAEUSSMYBHAthcxTvba4yT)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent d1f082d commit 69147ab

7 files changed

Lines changed: 461 additions & 2 deletions
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@objectstack/objectql': minor
3+
'@objectstack/lint': minor
4+
---
5+
6+
fix(objectql)!: the one-hop read through a reference never reads a stored-metadata-body table, and the lint refuses that read where it is written
7+
8+
Clause-②: no (narrowing)
9+
10+
<!-- adr-0087: not-required (no-migration-prescription) No stored metadata moves and nothing is converted: what narrows is a served value (a formula field or a validation rule that reads through a reference into a stored-metadata-body table) and an author-time verdict on such an expression; 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: both packages publish (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). -->
11+
12+
**BREAKING** (a served-set narrowing), shipped as `minor` under the launch-window convention for breaking changes.
13+
14+
The tables of the stored-metadata-body family (`STORED_METADATA_BODY_OBJECTS`) are projected or refused at every door that serves them. The one-hop read a formula field and a validation rule share (`resolveOneHopRelated`) was an ordinary engine read, which the family's projection does not reach.
15+
16+
- **`@objectstack/objectql`.** A hop whose target is a family table is not read, whichever authority the hop is read under. A formula field reading through it is `null` with its reason, on every read, write response and `serveFormulaField` call (the seam a hook body's `ctx.title()` reads through). A validation rule reading through it has no verdict, so the write it judges is refused. The answer is the one an unreadable related record gets, so it says nothing about the row. A hop into any other object is unchanged.
17+
- **`@objectstack/lint`.** A formula field's `expression`, or a validation rule's hydrated `condition`, that reads through a reference into a family table is an error, with the prescription. `os validate`, `os build` and the object save door run the same pass, so the author learns it at save rather than from a `null`.
18+
19+
What to do. Remove the read through the reference. Stored metadata is read through the metadata API (`GET /api/v1/meta/:type/:name`), where its body is projected. Reading the reference field itself (the stored id) is unchanged.
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#24316] The lint refuses a one-hop read through a reference into a
5+
* stored-metadata-body table, at the two sites that serve a hop: a formula
6+
* field's `expression` and a validation rule's hydrated `condition`.
7+
*
8+
* ObjectQL's one-hop loader never reads that hop (its own pin is
9+
* `@objectstack/objectql`'s `engine-formula-hop-stored-metadata-body.test.ts`),
10+
* so as written the formula reads null and the rule refuses every write. These
11+
* pins hold that the author learns it where the expression is written — on
12+
* `os validate` / `os build` and on the object save door, which runs the same
13+
* passes — and that a hop into any other object, and a read of the stored id
14+
* alone, are untouched.
15+
*/
16+
import { describe, expect, it } from 'vitest';
17+
import { STORED_METADATA_BODY_OBJECTS } from '@objectstack/spec/kernel';
18+
import { runStackExpressionPasses, validateStackExpressions } from './validate-expressions.js';
19+
20+
const carrier = (target: string, fields: Record<string, unknown>, validations?: unknown[]) => ({
21+
name: 'probe_carrier',
22+
fields: {
23+
title: { type: 'text' },
24+
meta: { type: 'lookup', reference: target },
25+
other: { type: 'lookup', reference: 'probe_other' },
26+
...fields,
27+
},
28+
...(validations ? { validations } : {}),
29+
});
30+
const other = { name: 'probe_other', fields: { name: { type: 'text' } } };
31+
const hopIssues = (issues: ReturnType<typeof validateStackExpressions>) =>
32+
issues.filter((i) => i.message.includes('a stored-metadata table'));
33+
34+
describe('[#24316] a hop into a stored-metadata-body table is refused at author time', () => {
35+
for (const target of STORED_METADATA_BODY_OBJECTS) {
36+
it(`a formula reading through a reference to '${target}' is an error naming the reference and the table`, () => {
37+
const stack = {
38+
objects: [carrier(target, {
39+
body_out: { type: 'formula', expression: 'record.meta.metadata' },
40+
hash_out: { type: 'formula', expression: 'record.meta.checksum' },
41+
}), other],
42+
};
43+
const found = hopIssues(validateStackExpressions(stack));
44+
expect(found.map((i) => i.where)).toEqual([
45+
"object 'probe_carrier' · field 'body_out' expression",
46+
"object 'probe_carrier' · field 'hash_out' expression",
47+
]);
48+
for (const i of found) {
49+
expect(i.severity).toBe('error');
50+
expect(i.message).toContain(`\`record.meta\` is a reference to '${target}'`);
51+
expect(i.message).toContain('this formula reads null on every row');
52+
}
53+
});
54+
55+
it(`a validation condition reading through a reference to '${target}' is an error, nested conditions included`, () => {
56+
const stack = {
57+
objects: [carrier(target, {}, [
58+
{ name: 'top', type: 'script', condition: "record.meta.metadata.contains('x')", message: 'm' },
59+
{
60+
name: 'nested', type: 'conditional', when: 'record.title != null', message: 'm',
61+
then: { name: 'inner', type: 'script', condition: "record.meta.type == 'datasource'", message: 'm' },
62+
},
63+
]), other],
64+
};
65+
const found = hopIssues(validateStackExpressions(stack));
66+
expect(found).toHaveLength(2);
67+
expect(found[0].where).toBe("object 'probe_carrier' · validation 'top'");
68+
expect(found[1].where).toContain("object 'probe_carrier' · ");
69+
for (const i of found) {
70+
expect(i.severity).toBe('error');
71+
expect(i.message).toContain('this rule has no verdict and every write it judges is refused');
72+
}
73+
});
74+
}
75+
76+
it('the object save door (`runtimeWriteType: object`) refuses the same hop', () => {
77+
const stack = {
78+
objects: [carrier('sys_metadata', { body_out: { type: 'formula', expression: 'record.meta.metadata' } }), other],
79+
};
80+
expect(hopIssues(runStackExpressionPasses(stack, { runtimeWriteType: 'object' }))).toHaveLength(1);
81+
});
82+
83+
it('a hop into any other object, and a read of the stored id alone, are untouched', () => {
84+
const stack = {
85+
objects: [carrier('sys_metadata', {
86+
other_name: { type: 'formula', expression: 'record.other.name' },
87+
meta_id: { type: 'formula', expression: 'record.meta' },
88+
}, [{ name: 'r', type: 'script', condition: 'record.other.name == record.title', message: 'm' }]), other],
89+
};
90+
expect(hopIssues(validateStackExpressions(stack))).toEqual([]);
91+
});
92+
});

‎packages/lint/src/validate-expressions.test.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3542,6 +3542,13 @@ describe('validateStackExpressions — reads only keys the spec declares (meta-t
35423542
// "key" is `Map.prototype.get`; the metadata keys it is built from are read
35433543
// in `flow-cel-root-scope.ts`, not here.
35443544
'flowEntrances',
3545+
// [#24316] The stored-metadata-body hop refusal: the per-object index of
3546+
// references into the family (object name → field → target), one
3547+
// object's entry of it, and the formula package's traversal analysis
3548+
// (field → related columns). Their keys are Map / Set methods, never
3549+
// metadata keys; the metadata keys the index is built from (`type`,
3550+
// `reference`) are read off the tabled `def` receiver.
3551+
'storedMetadataBodyReferenceIndex', 'familyRefs', 'traversals',
35453552
]);
35463553
expect(receivers.filter((r) => !tabled.has(r) && !PLUMBING.has(r))).toEqual([]);
35473554
});

‎packages/lint/src/validate-expressions.ts‎

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@ import type { FlowNodeParsed, FlowEdgeParsed } from '@objectstack/spec/automatio
116116
// shared refusal to prevent.
117117
import { EvaluatedExpressionInputSchema, EVALUATED_EXPRESSION_SOURCE_REQUIRED } from '@objectstack/spec/shared';
118118
import { referenceCarrierOf, REFERENCE_VALUE_TYPES } from '@objectstack/spec/data';
119+
import { isStoredMetadataBodyObject } from '@objectstack/spec/kernel';
119120
// [#22394] The acting user's declared members — the contract half of the option
120121
// verdict's member allowlist for `current_user` and its ADR-0068 aliases. Read
121122
// off the schema at first use, never restated here.
@@ -1723,6 +1724,50 @@ function singleMasterOf(obj: AnyRec): string | undefined {
17231724
return targets.length === 1 ? targets[0] : undefined;
17241725
}
17251726

1727+
/**
1728+
* [#24316] An object's reference fields whose target is a stored-metadata-body
1729+
* table ({@link isStoredMetadataBodyObject}), field → target. A hop through one
1730+
* of them is the read ObjectQL's one-hop loader refuses for both seams that
1731+
* serve a hop, so the lint refuses it where it is written.
1732+
*
1733+
* The literal `.reference` read stays in the argument for the #5017 receiver
1734+
* scan, exactly as in {@link masterDetailCount}. A type whose target is implied
1735+
* (`user`, `tree`) never names the family, so the authored carrier is the only
1736+
* one read.
1737+
*/
1738+
function storedMetadataBodyReferencesOf(obj: AnyRec): Map<string, string> {
1739+
const out = new Map<string, string>();
1740+
for (const [name, def] of fieldEntries(obj)) {
1741+
if (typeof def.type !== 'string' || !REFERENCE_VALUE_TYPES.has(def.type)) continue;
1742+
const ref = referenceCarrierOf({ reference: def.reference }, 'validate-expressions storedMetadataBodyReferencesOf');
1743+
if (ref !== undefined && isStoredMetadataBodyObject(ref.trim())) out.set(name, ref.trim());
1744+
}
1745+
return out;
1746+
}
1747+
1748+
/**
1749+
* [#24316] The refusal of a one-hop read into a stored-metadata-body table, at
1750+
* one of the two sites that serve a hop. It names the reference, the table and
1751+
* the columns read, and never anything a row holds.
1752+
*/
1753+
function storedMetadataBodyHopMessage(
1754+
site: 'formula' | 'rule',
1755+
field: string,
1756+
target: string,
1757+
columns: string,
1758+
): string {
1759+
const consequence = site === 'formula'
1760+
? 'so this formula reads null on every row'
1761+
: 'so this rule has no verdict and every write it judges is refused';
1762+
const read = celMemberPath('record', field);
1763+
return `${read} is a reference to '${target}', a stored-metadata table, and this ${site} reads `
1764+
+ `${columns} through it. A formula or a validation rule never reads through `
1765+
+ `a reference into a stored-metadata table: the hop is not read, ${consequence}. Those rows carry stored `
1766+
+ 'metadata bodies, which are served only through the metadata API (`GET /api/v1/meta/:type/:name`), '
1767+
+ `where the body is projected. Remove the read through \`${field}\`; ${read} alone is the `
1768+
+ 'stored id, if the id is what you need.';
1769+
}
1770+
17261771
/**
17271772
* [#17495] Why a structural condition whose SHAPE is fine is still not
17281773
* authorable: its source is blank. The author-time twin of the gate #17322 put
@@ -1857,6 +1902,51 @@ export function runStackExpressionPasses(stack: AnyRec, options: StackExpression
18571902
const attachedOnReadIndex = buildAttachedOnReadIndex(objects);
18581903
const fieldTypeIndex = buildFieldTypeIndex(objects);
18591904
const nullableIndex = buildNullableFieldIndex(objects);
1905+
// [#24316] object name → its references into a stored-metadata-body table,
1906+
// read the first time a site of that object serves a hop — never before the
1907+
// walk reaches the object, so a carrier the spec cannot read is refused by the
1908+
// pass that owned that verdict before this one existed.
1909+
const storedMetadataBodyReferenceIndex = new Map<string, Map<string, string>>();
1910+
const storedMetadataBodyReferencesFor = (objectName: string): Map<string, string> => {
1911+
let familyRefs = storedMetadataBodyReferenceIndex.get(objectName);
1912+
if (!familyRefs) {
1913+
familyRefs = new Map();
1914+
for (const obj of objects) if (obj.name === objectName) familyRefs = storedMetadataBodyReferencesOf(obj);
1915+
storedMetadataBodyReferenceIndex.set(objectName, familyRefs);
1916+
}
1917+
return familyRefs;
1918+
};
1919+
/**
1920+
* [#24316] Refuse a one-hop read through a reference into a
1921+
* stored-metadata-body table, at the two sites that serve a hop: a
1922+
* validation rule's hydrated `condition` ({@link check} with
1923+
* `traversalHydration`) and a formula field's `expression`
1924+
* ({@link judgeFieldFormula}). ObjectQL's one-hop loader never reads that
1925+
* hop, so as written the field reads null or the rule refuses every write;
1926+
* this says so where the expression is written, with the prescription.
1927+
*/
1928+
const refuseStoredMetadataBodyHop = (
1929+
where: string,
1930+
raw: unknown,
1931+
objectName: string,
1932+
site: 'formula' | 'rule',
1933+
): void => {
1934+
const source = celSourceOf(raw);
1935+
if (!source) return;
1936+
const traversals = analyzeRelationshipTraversals(source, 'record')?.traversals;
1937+
if (!traversals || traversals.size === 0) return;
1938+
const familyRefs = storedMetadataBodyReferencesFor(objectName);
1939+
for (const [field, columns] of traversals) {
1940+
const target = familyRefs.get(field);
1941+
if (!target) continue;
1942+
issues.push({
1943+
where,
1944+
message: storedMetadataBodyHopMessage(site, field, target, Array.from(columns, (c) => `'${c}'`).sort().join(', ')),
1945+
source,
1946+
severity: 'error',
1947+
});
1948+
}
1949+
};
18601950

18611951
// [#8116] object name → the injected anchors registered with NO storage
18621952
// behind them (non-empty only for ADR-0015 `external` objects). The other
@@ -2036,6 +2126,7 @@ export function runStackExpressionPasses(stack: AnyRec, options: StackExpression
20362126
issues.push({ where, message: e.message, source: e.source, severity: 'error' });
20372127
}
20382128
for (const w of res.warnings) issues.push({ where, message: w.message, source: w.source, severity: 'warning' });
2129+
if (objectName && traversalHydration) refuseStoredMetadataBodyHop(where, raw, objectName, 'rule');
20392130
// [#8116] Provenance rides every object-bound predicate this helper
20402131
// validates, whichever scope: the `record`/`previous` roots are explicit
20412132
// in the source, so flattened flow conditions are covered without ever
@@ -2756,6 +2847,7 @@ export function runStackExpressionPasses(stack: AnyRec, options: StackExpression
27562847
const fieldWhere = `object '${objectName}' · field '${fname}' expression`;
27572848
for (const e of res.errors) issues.push({ where: fieldWhere, message: e.message, source: e.source, severity: 'error' });
27582849
for (const w of res.warnings) issues.push({ where: fieldWhere, message: w.message, source: w.source, severity: 'warning' });
2850+
if (objectName) refuseStoredMetadataBodyHop(fieldWhere, f.expression, objectName, 'formula');
27592851
// [#8116] Formulas read the same record binding the predicates do, so
27602852
// a `record.<anchor>` read inside one degrades identically.
27612853
warnUnprovisionedAnchors(fieldWhere, f.expression, objectName);

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

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,11 @@
3535
* (`stdio-data-bridge.stored-metadata-body.test.ts`, which also enumerates
3636
* every bridge member and every engine read call site of that package, so a
3737
* new reader there fails its pin instead of joining silently) [#21207];
38+
* - [#24316] the one-hop read through a reference that a formula field and a
39+
* validation rule share → `@objectstack/objectql`
40+
* (`engine-formula-hop-stored-metadata-body.test.ts`: the engine never reads
41+
* a hop into the family), and its author-time refusal → `@objectstack/lint`
42+
* (`validate-expressions.stored-metadata-body-hop.test.ts`);
3843
* - the generic data door reads / groupBy / filter+sort → this package
3944
* (`protocol.data-door-stored-metadata-redaction.test.ts`, and the behaviour
4045
* asserted below).
@@ -124,6 +129,25 @@ const FAMILY_SURFACES = [
124129
disposition: 'refuses',
125130
pin: 'this package (protocol.data-door-stored-content-hash.test.ts) + @objectstack/mcp + @objectstack/service-analytics',
126131
},
132+
// [#24316] The one-hop read through a reference: a formula field (caller
133+
// authority) and a validation rule (SYSTEM authority) share one loader in the
134+
// engine, which never reads a hop whose target is a family object, and the
135+
// lint refuses the same hop where it is written.
136+
{
137+
surface: 'formula one-hop read through a reference: the carrying object\'s data read, a hook body\'s ctx.api read, ctx.title(), a write response',
138+
disposition: 'refuses',
139+
pin: '@objectstack/objectql (engine-formula-hop-stored-metadata-body.test.ts)',
140+
},
141+
{
142+
surface: 'validation-rule one-hop read through a reference (read as SYSTEM)',
143+
disposition: 'refuses',
144+
pin: '@objectstack/objectql (engine-formula-hop-stored-metadata-body.test.ts)',
145+
},
146+
{
147+
surface: 'author time: a formula or validation condition reading through a reference into the family (os validate, the object save door)',
148+
disposition: 'refuses',
149+
pin: '@objectstack/lint (validate-expressions.stored-metadata-body-hop.test.ts)',
150+
},
127151
] as const;
128152

129153
/** A stored datasource body as it sits in the `metadata` column: serialized JSON with credential material. */
@@ -194,8 +218,9 @@ describe('[#21120] stored-metadata-body family — the data door exposes only co
194218
// One row per the three local data-door surfaces + five cross-package ones
195219
// (audit, analytics, realtime, and the MCP stdio reader's seam and refusal),
196220
// + [#21207] the thirteen surfaces of the stored content hash, and the two
197-
// of the history change note that can quote one.
198-
expect(FAMILY_SURFACES).toHaveLength(23);
221+
// 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);
199224
});
200225
});
201226

0 commit comments

Comments
 (0)