Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .changeset/24316-formula-hop-family-refusal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
'@objectstack/objectql': minor
'@objectstack/lint': minor
---

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

Clause-②: no (narrowing)

<!-- 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). -->

**BREAKING** (a served-set narrowing), shipped as `minor` under the launch-window convention for breaking changes.

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.

- **`@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.
- **`@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`.

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.
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#24316] The lint refuses a one-hop read through a reference into a
* stored-metadata-body table, at the two sites that serve a hop: a formula
* field's `expression` and a validation rule's hydrated `condition`.
*
* ObjectQL's one-hop loader never reads that hop (its own pin is
* `@objectstack/objectql`'s `engine-formula-hop-stored-metadata-body.test.ts`),
* so as written the formula reads null and the rule refuses every write. These
* pins hold that the author learns it where the expression is written — on
* `os validate` / `os build` and on the object save door, which runs the same
* passes — and that a hop into any other object, and a read of the stored id
* alone, are untouched.
*/
import { describe, expect, it } from 'vitest';
import { STORED_METADATA_BODY_OBJECTS } from '@objectstack/spec/kernel';
import { runStackExpressionPasses, validateStackExpressions } from './validate-expressions.js';

const carrier = (target: string, fields: Record<string, unknown>, validations?: unknown[]) => ({
name: 'probe_carrier',
fields: {
title: { type: 'text' },
meta: { type: 'lookup', reference: target },
other: { type: 'lookup', reference: 'probe_other' },
...fields,
},
...(validations ? { validations } : {}),
});
const other = { name: 'probe_other', fields: { name: { type: 'text' } } };
const hopIssues = (issues: ReturnType<typeof validateStackExpressions>) =>
issues.filter((i) => i.message.includes('a stored-metadata table'));

describe('[#24316] a hop into a stored-metadata-body table is refused at author time', () => {
for (const target of STORED_METADATA_BODY_OBJECTS) {
it(`a formula reading through a reference to '${target}' is an error naming the reference and the table`, () => {
const stack = {
objects: [carrier(target, {
body_out: { type: 'formula', expression: 'record.meta.metadata' },
hash_out: { type: 'formula', expression: 'record.meta.checksum' },
}), other],
};
const found = hopIssues(validateStackExpressions(stack));
expect(found.map((i) => i.where)).toEqual([
"object 'probe_carrier' · field 'body_out' expression",
"object 'probe_carrier' · field 'hash_out' expression",
]);
for (const i of found) {
expect(i.severity).toBe('error');
expect(i.message).toContain(`\`record.meta\` is a reference to '${target}'`);
expect(i.message).toContain('this formula reads null on every row');
}
});

it(`a validation condition reading through a reference to '${target}' is an error, nested conditions included`, () => {
const stack = {
objects: [carrier(target, {}, [
{ name: 'top', type: 'script', condition: "record.meta.metadata.contains('x')", message: 'm' },
{
name: 'nested', type: 'conditional', when: 'record.title != null', message: 'm',
then: { name: 'inner', type: 'script', condition: "record.meta.type == 'datasource'", message: 'm' },
},
]), other],
};
const found = hopIssues(validateStackExpressions(stack));
expect(found).toHaveLength(2);
expect(found[0].where).toBe("object 'probe_carrier' · validation 'top'");
expect(found[1].where).toContain("object 'probe_carrier' · ");
for (const i of found) {
expect(i.severity).toBe('error');
expect(i.message).toContain('this rule has no verdict and every write it judges is refused');
}
});
}

it('the object save door (`runtimeWriteType: object`) refuses the same hop', () => {
const stack = {
objects: [carrier('sys_metadata', { body_out: { type: 'formula', expression: 'record.meta.metadata' } }), other],
};
expect(hopIssues(runStackExpressionPasses(stack, { runtimeWriteType: 'object' }))).toHaveLength(1);
});

it('a hop into any other object, and a read of the stored id alone, are untouched', () => {
const stack = {
objects: [carrier('sys_metadata', {
other_name: { type: 'formula', expression: 'record.other.name' },
meta_id: { type: 'formula', expression: 'record.meta' },
}, [{ name: 'r', type: 'script', condition: 'record.other.name == record.title', message: 'm' }]), other],
};
expect(hopIssues(validateStackExpressions(stack))).toEqual([]);
});
});
7 changes: 7 additions & 0 deletions packages/lint/src/validate-expressions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3542,6 +3542,13 @@ describe('validateStackExpressions — reads only keys the spec declares (meta-t
// "key" is `Map.prototype.get`; the metadata keys it is built from are read
// in `flow-cel-root-scope.ts`, not here.
'flowEntrances',
// [#24316] The stored-metadata-body hop refusal: the per-object index of
// references into the family (object name → field → target), one
// object's entry of it, and the formula package's traversal analysis
// (field → related columns). Their keys are Map / Set methods, never
// metadata keys; the metadata keys the index is built from (`type`,
// `reference`) are read off the tabled `def` receiver.
'storedMetadataBodyReferenceIndex', 'familyRefs', 'traversals',
]);
expect(receivers.filter((r) => !tabled.has(r) && !PLUMBING.has(r))).toEqual([]);
});
Expand Down
92 changes: 92 additions & 0 deletions packages/lint/src/validate-expressions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ import type { FlowNodeParsed, FlowEdgeParsed } from '@objectstack/spec/automatio
// shared refusal to prevent.
import { EvaluatedExpressionInputSchema, EVALUATED_EXPRESSION_SOURCE_REQUIRED } from '@objectstack/spec/shared';
import { referenceCarrierOf, REFERENCE_VALUE_TYPES } from '@objectstack/spec/data';
import { isStoredMetadataBodyObject } from '@objectstack/spec/kernel';
// [#22394] The acting user's declared members — the contract half of the option
// verdict's member allowlist for `current_user` and its ADR-0068 aliases. Read
// off the schema at first use, never restated here.
Expand Down Expand Up @@ -1723,6 +1724,50 @@ function singleMasterOf(obj: AnyRec): string | undefined {
return targets.length === 1 ? targets[0] : undefined;
}

/**
* [#24316] An object's reference fields whose target is a stored-metadata-body
* table ({@link isStoredMetadataBodyObject}), field → target. A hop through one
* of them is the read ObjectQL's one-hop loader refuses for both seams that
* serve a hop, so the lint refuses it where it is written.
*
* The literal `.reference` read stays in the argument for the #5017 receiver
* scan, exactly as in {@link masterDetailCount}. A type whose target is implied
* (`user`, `tree`) never names the family, so the authored carrier is the only
* one read.
*/
function storedMetadataBodyReferencesOf(obj: AnyRec): Map<string, string> {
const out = new Map<string, string>();
for (const [name, def] of fieldEntries(obj)) {
if (typeof def.type !== 'string' || !REFERENCE_VALUE_TYPES.has(def.type)) continue;
const ref = referenceCarrierOf({ reference: def.reference }, 'validate-expressions storedMetadataBodyReferencesOf');
if (ref !== undefined && isStoredMetadataBodyObject(ref.trim())) out.set(name, ref.trim());
}
return out;
}

/**
* [#24316] The refusal of a one-hop read into a stored-metadata-body table, at
* one of the two sites that serve a hop. It names the reference, the table and
* the columns read, and never anything a row holds.
*/
function storedMetadataBodyHopMessage(
site: 'formula' | 'rule',
field: string,
target: string,
columns: string,
): string {
const consequence = site === 'formula'
? 'so this formula reads null on every row'
: 'so this rule has no verdict and every write it judges is refused';
const read = celMemberPath('record', field);
return `${read} is a reference to '${target}', a stored-metadata table, and this ${site} reads `
+ `${columns} through it. A formula or a validation rule never reads through `
+ `a reference into a stored-metadata table: the hop is not read, ${consequence}. Those rows carry stored `
+ 'metadata bodies, which are served only through the metadata API (`GET /api/v1/meta/:type/:name`), '
+ `where the body is projected. Remove the read through \`${field}\`; ${read} alone is the `
+ 'stored id, if the id is what you need.';
}

/**
* [#17495] Why a structural condition whose SHAPE is fine is still not
* authorable: its source is blank. The author-time twin of the gate #17322 put
Expand Down Expand Up @@ -1857,6 +1902,51 @@ export function runStackExpressionPasses(stack: AnyRec, options: StackExpression
const attachedOnReadIndex = buildAttachedOnReadIndex(objects);
const fieldTypeIndex = buildFieldTypeIndex(objects);
const nullableIndex = buildNullableFieldIndex(objects);
// [#24316] object name → its references into a stored-metadata-body table,
// read the first time a site of that object serves a hop — never before the
// walk reaches the object, so a carrier the spec cannot read is refused by the
// pass that owned that verdict before this one existed.
const storedMetadataBodyReferenceIndex = new Map<string, Map<string, string>>();
const storedMetadataBodyReferencesFor = (objectName: string): Map<string, string> => {
let familyRefs = storedMetadataBodyReferenceIndex.get(objectName);
if (!familyRefs) {
familyRefs = new Map();
for (const obj of objects) if (obj.name === objectName) familyRefs = storedMetadataBodyReferencesOf(obj);
storedMetadataBodyReferenceIndex.set(objectName, familyRefs);
}
return familyRefs;
};
/**
* [#24316] Refuse a one-hop read through a reference into a
* stored-metadata-body table, at the two sites that serve a hop: a
* validation rule's hydrated `condition` ({@link check} with
* `traversalHydration`) and a formula field's `expression`
* ({@link judgeFieldFormula}). ObjectQL's one-hop loader never reads that
* hop, so as written the field reads null or the rule refuses every write;
* this says so where the expression is written, with the prescription.
*/
const refuseStoredMetadataBodyHop = (
where: string,
raw: unknown,
objectName: string,
site: 'formula' | 'rule',
): void => {
const source = celSourceOf(raw);
if (!source) return;
const traversals = analyzeRelationshipTraversals(source, 'record')?.traversals;
if (!traversals || traversals.size === 0) return;
const familyRefs = storedMetadataBodyReferencesFor(objectName);
for (const [field, columns] of traversals) {
const target = familyRefs.get(field);
if (!target) continue;
issues.push({
where,
message: storedMetadataBodyHopMessage(site, field, target, Array.from(columns, (c) => `'${c}'`).sort().join(', ')),
source,
severity: 'error',
});
}
};

// [#8116] object name → the injected anchors registered with NO storage
// behind them (non-empty only for ADR-0015 `external` objects). The other
Expand Down Expand Up @@ -2036,6 +2126,7 @@ export function runStackExpressionPasses(stack: AnyRec, options: StackExpression
issues.push({ where, message: e.message, source: e.source, severity: 'error' });
}
for (const w of res.warnings) issues.push({ where, message: w.message, source: w.source, severity: 'warning' });
if (objectName && traversalHydration) refuseStoredMetadataBodyHop(where, raw, objectName, 'rule');
// [#8116] Provenance rides every object-bound predicate this helper
// validates, whichever scope: the `record`/`previous` roots are explicit
// in the source, so flattened flow conditions are covered without ever
Expand Down Expand Up @@ -2756,6 +2847,7 @@ export function runStackExpressionPasses(stack: AnyRec, options: StackExpression
const fieldWhere = `object '${objectName}' · field '${fname}' expression`;
for (const e of res.errors) issues.push({ where: fieldWhere, message: e.message, source: e.source, severity: 'error' });
for (const w of res.warnings) issues.push({ where: fieldWhere, message: w.message, source: w.source, severity: 'warning' });
if (objectName) refuseStoredMetadataBodyHop(fieldWhere, f.expression, objectName, 'formula');
// [#8116] Formulas read the same record binding the predicates do, so
// a `record.<anchor>` read inside one degrades identically.
warnUnprovisionedAnchors(fieldWhere, f.expression, objectName);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@
* (`stdio-data-bridge.stored-metadata-body.test.ts`, which also enumerates
* every bridge member and every engine read call site of that package, so a
* new reader there fails its pin instead of joining silently) [#21207];
* - [#24316] the one-hop read through a reference that a formula field and a
* validation rule share → `@objectstack/objectql`
* (`engine-formula-hop-stored-metadata-body.test.ts`: the engine never reads
* a hop into the family), and its author-time refusal → `@objectstack/lint`
* (`validate-expressions.stored-metadata-body-hop.test.ts`);
* - the generic data door reads / groupBy / filter+sort → this package
* (`protocol.data-door-stored-metadata-redaction.test.ts`, and the behaviour
* asserted below).
Expand Down Expand Up @@ -124,6 +129,25 @@ const FAMILY_SURFACES = [
disposition: 'refuses',
pin: 'this package (protocol.data-door-stored-content-hash.test.ts) + @objectstack/mcp + @objectstack/service-analytics',
},
// [#24316] The one-hop read through a reference: a formula field (caller
// authority) and a validation rule (SYSTEM authority) share one loader in the
// engine, which never reads a hop whose target is a family object, and the
// lint refuses the same hop where it is written.
{
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',
disposition: 'refuses',
pin: '@objectstack/objectql (engine-formula-hop-stored-metadata-body.test.ts)',
},
{
surface: 'validation-rule one-hop read through a reference (read as SYSTEM)',
disposition: 'refuses',
pin: '@objectstack/objectql (engine-formula-hop-stored-metadata-body.test.ts)',
},
{
surface: 'author time: a formula or validation condition reading through a reference into the family (os validate, the object save door)',
disposition: 'refuses',
pin: '@objectstack/lint (validate-expressions.stored-metadata-body-hop.test.ts)',
},
] as const;

/** A stored datasource body as it sits in the `metadata` column: serialized JSON with credential material. */
Expand Down Expand Up @@ -194,8 +218,9 @@ describe('[#21120] stored-metadata-body family — the data door exposes only co
// One row per the three local data-door surfaces + five cross-package ones
// (audit, analytics, realtime, and the MCP stdio reader's seam and refusal),
// + [#21207] the thirteen surfaces of the stored content hash, and the two
// of the history change note that can quote one.
expect(FAMILY_SURFACES).toHaveLength(23);
// of the history change note that can quote one, + [#24316] the three of
// the one-hop read through a reference.
expect(FAMILY_SURFACES).toHaveLength(26);
});
});

Expand Down
Loading
Loading