Skip to content

feat(objectql,lint): a formula field reads one hop through a reference — record.book.title computes, a second hop is refused - #24274

Merged
objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-24197-formula-one-hop-read
Oct 18, 2026
Merged

objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-24197-formula-one-hop-read

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

Part of #24197 — the code part of the ruling. The ruling's ADR-0079 note (docs/adr/**, Tier H) follows as its own PR, so this one does not close the card.

Clause-②: yes (narrowing) (the lint and the engine narrow what they accept; @objectstack/formula also widens its published index by one optional parameter and one exported type, TraversalSubject. Per clause2-line.mjs, a diff that widens one surface and narrows another reads yes (narrowing). No level moves.)

What changes

A formula field that reads through a reference field (record.book.title) now computes. Before, planFormulaProjection gave a formula only the row's own columns, so the reference held an id, the read faulted, and the field read null on every row. Meanwhile the lint admitted the expression.

Engine (packages/objectql).

  • The formula read path preloads the related fields a formula names ONE hop through a lookup or master-detail field. This covers find, findOne, and the record a create or update hands back. The read is one batched find per reference field per page, never one per row, and it names only id and the related fields the formulas name.
  • There is one loader. The validation-rule path's resolvePredicateRelated body moved into resolveOneHopRelated(schema, wanted, rows, context, authority), and both seams call it. Validation predicates keep authority: 'predicate': a SYSTEM read behind the caller's read doors, unchanged. Formulas use authority: 'caller': the hop is read under the reader's own context, through every layer find applies. The hop collector addReferenceTraversals and the conflict check referenceTraversalConflicts are extracted from rule-validator.ts, not re-implemented.
  • Verdicts are the validation path's own (RelatedFieldBinding). A related record that is not found, or that the reader may not read, a related field the related object does not declare, and a failed read each leave the formula unevaluated. It reads null, and the formula fault sink reports why, naming the related object and field. An empty reference is evaluated, so record.reader.?name.orValue('—') gives it a value. A plain read through an empty reference faults, and the reported reason names that optional read.
  • One hop is the depth. A second hop written out, and a reference read both through and as a plain value, are not evaluated. They read null with the lint's own sentence. A find that IS a formula's hop read (tracked by query-object identity in a WeakSet, never on a context) serves no hop of its own, so two objects whose titles read each other terminate.

Lint (packages/lint) and @objectstack/formula. judgeFieldFormula passes traversalHydration: true. validateExpression now honours the flag in the value role, and findTraversalConflicts takes a subject ('predicate' default, or 'formula') for the wording. Results:

  • record.book.title + ' · ' + record.reader.name passes.
  • record.book.shelf.code is refused (traversal-multi-hop), naming the field and the one-hop alternative.
  • record.reader == null ? … : record.reader.name is refused (traversal-bare-and-traversed), with the optional read as the repair.
  • Callers that do not opt in see no change.

Spec (packages/spec). The blueprint schema's expression and nameField describe strings, and their strict twins, gave "record.order_no + ' · ' + record.customer" and "{order_no} · {customer}". customer is a lookup, so those compose the stored id into the title. They now teach record.customer.name, say that a second hop is refused, and say that a formula title cannot be sorted by or searched. content/docs/references/ai/solution-blueprint.mdx is regenerated (check:generated --fix).

Two edits outside the claim's file list, both forced by gates on the moved code:

  • content/docs/permissions/system-context.mdx row 29b: the anchor moves from engine.ts#resolvePredicateRelated to engine.ts#resolveOneHopRelated, because the elevation read moved there (check:system-context-census).
  • federated-injected-column-readers.test.ts: its ledger rows are renamed to the moved function names (resolveOneHopRelated, addReferenceTraversals, referenceTraversalConflicts), with the same dispositions.

Sort and search (the ruling's measured choice)

A formula's value is stored nowhere. Drivers materialise no column for it, and the engine drops it from the driver projection. So a title formula over lookups is unsortable and unsearchable, and it is already loud wherever a sort or a search names it. Measured over HTTP on this branch, below: ?sort=title answers 400 INVALID_SORT and ?search=…&searchFields=title answers 400 INVALID_FIELD. The engine-level sort refusal is pinned in engine-formula-one-hop.test.ts. No engine change was needed. The describe strings now say so.

Verification

Head e9c02e6813 (after merging origin/main at 53ef752436).

Live backend over HTTP. examples/app-todo was booted with objectstack dev --fresh on this branch. A junction object was saved through PUT /api/v1/meta/object/lib_loan, with lookups book and reader and nameField: title, where title = record.book.title + ' · ' + record.reader.name.

request answer
save lib_loan with title = record.book.shelf.name (two hops) 422 INVALID_METADATA, object 'lib_loan' · field 'title' expression: "record.book is read through more than one relationship hop. A formula resolves ONE hop …"
save lib_loan with the one-hop composed title 200
POST /api/v1/data/lib_loan {book, reader} record.title = Dune · Bo
GET /api/v1/data/lib_loan?fields=title,book,reader Dune · Ann, Emma · Ann, Dune · Bo, and null for the loan with an empty reader. Rows still carry the foreign-key ids. The empty-reader fault is logged once, with the optional-read repair.
GET …?sort=title 400 INVALID_SORT
GET …?search=Dune&searchFields=title 400 INVALID_FIELD

Engine pins (packages/objectql/src/engine-formula-one-hop.test.ts, 17 tests, real ObjectQL and a driver that pages):

  • A page of 4 loans over 3 books and 2 readers makes exactly 1 lib_book read and 1 lib_reader read. The book read is where id $in [b1,b2,b3] with fields [id, title]. The next page reads only its own ids.
  • A projection naming only title answers { title } alone. findOne and the insert response are served.
  • Caller authority: a middleware scoping lib_reader rows for non-system callers leaves the formula null, with "not found by this read (it does not exist, or this reader may not read it)". The hidden name is absent from the response, and SYSTEM reads it. A middleware masking name keeps it out of the value.
  • A dangling reference reads null with a reason (no fallback evaluated). An empty reference with .?name.orValue('—') gives Emma · —. An undeclared related field is named on the related object.
  • A second hop is refused with no related read. The related object's own-column formulas compute on the hop read. Its own hop is not served (two hops via a formula). A ping/pong cycle terminates.
  • orderBy: title rejects with { code: 'INVALID_SORT', status: 400 }.

Lint pins (packages/lint/src/validate-expressions.formula-one-hop.test.ts, 10 tests): admit one hop and the optional read; refuse two hops and the bare-and-traversed read; the four blueprint describe strings carry the one-hop example; the example passes the field-formula pass with customer a lookup; the nameField strings state the sort and search answer. Formula pins (relationship-traversal.test.ts): the value role is judged when opted in, with formula wording and no rule repair, and is unchanged when not opted in.

Pin flipped (load-bearing). relationship-traversal.test.ts "checks nothing in the value role" asserted that an opted-in value source earned no traversal refusal. It now asserts the refusal's code, params and formula wording.

Ablations (scripts/ablation-replace.mjs; each mutation proven on disk, each restore proven blob == HEAD, git diff HEAD empty):

mechanism mutation result
hop preload resolveFormulaRelated returns no lookup 13 of 17 engine pins red
caller authority hop read as SYSTEM exactly the 2 caller-permission pins red
one-hop depth guard formulaHopReads.add removed second-hop-through-formula pin red; the full file no longer terminates (cycle recursion; killed at the timeout)
engine second-hop refusal hopConflict never set the second-hop pin red
lint verdict traversalHydration dropped from judgeFieldFormula the 2 lint refusal pins red
formula value arm arm back to predicate only the 2 formula refusal pins red

Suites and typechecks (exit codes captured before any pipe):

  • pnpm --filter @objectstack/formula test: 47 files, 1330 tests passed.
  • pnpm --filter @objectstack/lint test: 144 files, 6835 tests passed.
  • pnpm --filter @objectstack/objectql test ran on the pre-merge head: 437 of 438 files passed. The red file was the federated reader ledger, which this PR then fixed. After the merge, the 5 formula, predicate and ledger files were re-run: 82 tests passed.
  • typecheck passed (exit 0) for spec, formula, objectql (its check:test-typecheck included) and lint.
  • pnpm --filter @objectstack/spec check:generated: all 14 artifacts up to date.

Gates. node scripts/pm/dispatch-gates.mjs --commands (no paths) derived 117 commands on e9c02e6813. All 117 ran, and every one exited 0. The --ran reconciliation: "117 derived famil(ies) accounted for — 117 run, 0 NOT-MEASURED (a DERIVED zero …)".

Acceptance notes

  • evaluateFormulaField, the synchronous hook helper, preloads no hop. A traversing formula yields null there, as a can() formula already does. Its docblock says so.
  • A formula reached through a hop does not read a hop of its own (the declared depth). The lint does not see this shape, because it would have to resolve the related object's formula. The engine reports it at run time with the second-hop sentence.
  • H3's "a hop through a non-reference" is not refused. record.address.city on an object-valued field traverses today, and the validation path leaves it alone the same way.
  • Measured on the live backend and outside this card's fence: on an object with no stored text field (the loan above, whose only text-ish field is the formula title), a bare GET /api/v1/data/lib_loan?search=zzzz-nothing answers 200 with all 5 rows. The search is dropped silently, because the auto-default search set is empty. This is reported to the seat as a finding, not fixed here.
  • Over HTTP, the caller-permission case was measured with the engine pins (middleware composition), not with a second seeded user on the live backend.

Generated by Claude Code

…reference

The formula read path preloads the related fields a formula names one hop
through a reference field, page-batched under the reader's own read, sharing
the validation-rule path's loader. The lint admits one hop in a formula
expression and refuses two.

Claude-Session: https://claude.ai/code/session_01M6VD9XhattUEHKD99C7KUR
Co-authored-by: Claude <noreply@anthropic.com>
…n the blueprint describes

Claude-Session: https://claude.ai/code/session_01M6VD9XhattUEHKD99C7KUR
Co-authored-by: Claude <noreply@anthropic.com>
… loader; type the formula-hop test's query options

Claude-Session: https://claude.ai/code/session_01M6VD9XhattUEHKD99C7KUR
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions

github-actions Bot commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 4 package(s): @objectstack/formula, @objectstack/lint, @objectstack/objectql, @objectstack/spec, touching 30 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/formula/src/index.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

20 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 93b4acb57ec23c4e94a7047133ce612f89a13838.

⛔ 8 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/formula/src/index.ts) — pages documenting those are invisible to this run
  • 1 anchor(s) matched too much of the corpus to be a work list: ObjectQL (symbol, 72 pages)
  • 5 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 145 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 93b4acb57ec23c4e94a7047133ce612f89a13838 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from cd538ffd21f3b0528f5097a9d314bc4581315961 — the merge of head 7917005afdcb6a3a2db3a3a204f0605679890d13 into base 93b4acb57ec23c4e94a7047133ce612f89a13838, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin cd538ffd21f3b0528f5097a9d314bc4581315961 && git checkout cd538ffd21f3b0528f5097a9d314bc4581315961
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 93b4acb57ec23c4e94a7047133ce612f89a13838 7917005afdcb6a3a2db3a3a204f0605679890d13 && git checkout -B drift-repro 93b4acb57ec23c4e94a7047133ce612f89a13838 && git merge --no-ff 7917005afdcb6a3a2db3a3a204f0605679890d13

node scripts/docs-audit/affected-docs.mjs --json 93b4acb57ec23c4e94a7047133ce612f89a13838

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 93b4acb57ec23c4e94a7047133ce612f89a13838 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: e9c02e68133e6f68004af8682a6c07369e517250
Local-runs: none
Scope: packages/spec/src/ai/solution-blueprint.zod.ts, packages/objectql/src/engine.ts, packages/objectql/src/validation/rule-validator.ts, packages/formula/src/relationship-traversal.ts, packages/formula/src/validate.ts, packages/formula/src/index.ts, packages/lint/src/validate-expressions.ts, packages/objectql/src/engine-formula-one-hop.test.ts, packages/lint/src/validate-expressions.formula-one-hop.test.ts, packages/formula/src/relationship-traversal.test.ts, packages/objectql/src/federated-injected-column-readers.test.ts, content/docs/permissions/system-context.mdx + packages/objectql/src/record-title.ts, packages/runtime/src/sandbox/body-runner.ts, packages/objectql/src/index.ts, packages/objectql/src/core.ts, packages/objectql/package.json, packages/spec/src/data/field-value.zod.ts, packages/spec/src/data/search-fields.ts, packages/objectql/src/search-filter.ts, packages/metadata-protocol/src/protocol.ts, packages/platform-objects/src/identity/*.object.ts (formula census), content/docs/data-modeling/formulas.mdx, scripts/check-changeset-no-major.mjs, scripts/check-adr-0087-registration.mjs, scripts/pm/clause2-line.mjs

Inputs: card #24197 (its body with the Ruled: line; comments 6237225524, 6238165661, 6245950717, 6245997444, 6247217617), PR #24274 (body, the 17-file list, the one docs-drift comment, no review comments or reviews), the net diff origin/main...e9c02e6813 (merge base 53ef752, +1074/−68) read with git show and git diff, and the head's check-runs. The merge base of this diff differs from origin/main (93b4acb) only by main's own later commits; the three-dot diff was taken after a fresh fetch.

Check-runs at read time (2026-10-18T07:53:58Z): 39 runs. 27 completed success, among them Check Changeset, Governed Surface Queue Guard, Build Core, Build Docs, every Lint job (CI/release/engine gates, ESLint, PM dispatch-gates, docs and code guards, repo and tooling gates, the three scoped jobs), Type Check source gates / consumer gates / debt ledger, Spec property liveness, Dogfood Regression Gate, and the three claim and card guards. 5 skipped by design (Console Pin Gate, Dogfood shards, Dogfood Verify CLI, Temporal Conformance, Packed-tarball smoke). 7 still in progress and not waited on: Test Core 1/6 to 6/6 and Type Check · workspace. Those seven are the gate verdict on the new pins and the moved loader; this record does not stand in for them.

① Derived judgments

Judged against the Ruled: line verbatim: "The formula read path preloads one hop of the referenced fields a formula names (page-batched, as validation rules do); lint admits one hop and refuses two; a title formula over lookups sorts and searches by its stored value or is declared unsortable — written as an ADR-0079 note."

  1. The read path preloads one hop — RIGHT. planFormulaProjection plans hops per formula off collectReferenceTraversals, which is collectPredicateRelationships's own inlined addFrom moved verbatim into addReferenceTraversals (dialect check, referenceTargetOf filter, the same fold). applyFormulaPlan evaluates each traversing formula against a per-formula shallow copy whose reference slot holds the related row, so the row the read returns keeps the foreign key (pinned). Served on find, findOne, and the insert and update responses: hydrateWriteFormulas gained relatedFor, and both of its call sites (insert, update) pass it. Fields typed lookup, master_detail, user (implicit sys_user) and a registry-materialised tree self-reference all traverse, because both seams ask referenceTargetOf.

  2. Page-batched, one read per reference field per page — RIGHT. resolveFormulaRelated folds the plan's hops into one wanted map and calls resolveOneHopRelated once over the page the driver returned (after limit and offset); the loader issues one find per reference field with where id $in [the page's distinct FKs] and fields [id, ...named], never one per row (pinned: four loans over three books and two readers make one book read and one reader read; the second page reads only its own ids; a projection naming only the formula is widened for the read and cut back). A plan with no hop costs nothing (wanted.size === 0 returns before any closure or query); a hopConflict entry asks for nothing. The traversal analysis is memoised (analysisFor, capped cache). Bound: the distinct FKs of the page the caller asked for. The engine fills no default limit (fillQueryAstDefaults parses orderBy and expand only; the spec's limit carries no default), so a direct find with no limit makes one $in over every distinct FK of the whole result; REST callers are bounded by their page. The related read carries no limit, so it is never truncated.

  3. "As validation rules do" — RIGHT on the mechanism, and RIGHT to diverge on authority. The diff of resolvePredicateRelated at the merge base against resolveOneHopRelated at the head is exactly: the referential-clear guard and collectPredicateRelationships stay in the wrapper; bound becomes authority === 'predicate' && !isSystem; the hop query's context is the caller's when authority === 'caller' and { ...context, isSystem: true } otherwise; the WeakSet marker is added for caller only; the warn wording branches. Nothing else moves, so for authority: 'predicate' the validation path's behaviour (SYSTEM behind the caller's read doors, the master-detail header door, the federated and no-tenant own-read, the walled no-tenant readsNothing) is unchanged — proven by the diff, not asserted. The formula arm reads as the CALLER through the public find (RLS composition at the ADR-0139 boundary, the middleware chain, the FLS result mask, the organization wall, the related object's own hooks), which is the right authority for data handed to a reader. A related row the caller's read does not return is unresolved, and the refusal sentence names neither "missing" nor "hidden" (pinned: the hidden reader's name is absent from the response; SYSTEM reads it).

  4. Refusals at read time — RIGHT. Not found or not readable → unresolved; the related object declares no such field → undeclared-field; the read threw (a PERMISSION_DENIED from the middleware included) → unreadable. Each leaves the formula UNEVALUATED: null plus the reason to the fault sink, so no optional read can turn a missing or hidden record into the author's fallback (pinned: a dangling b_gone with .?name.orValue('—') reads null). An EMPTY reference is evaluated against the stored empty value; a plain read through it faults and the fault names record.FIELD.?RELATED.orValue('…'). The fault sink stays logger.warn, once per (object, field) per engine instance; nothing of it reaches the response. On every read path examined (find, findOne, the list route through find, insert and update responses) a formula discloses nothing the caller could not read directly; the by-name meta read returns metadata and evaluates no formula.

  5. Lint admits one hop and refuses two — RIGHT. judgeFieldFormula passes traversalHydration: true for a formula expression, the only value-role opt-in at this head (the check helper passes the flag on rule conditions only, as before). validateExpression honours the flag for value with subject: 'formula', through the same readRootMembers analyser and the same REFERENCE_VALUE_TYPES arbiter as the predicate arm. One hop passes, as it already did; record.book.shelf.code is refused traversal-multi-hop with the formula wording and no rule-shaped repair; the metadata save door runs this pass, so an active save answers 422 INVALID_METADATA (measured in the report; a draft save defers to promotion as before). Callers that do not pass the flag see no change (pinned).

  6. The bare-and-traversed refusal (record.reader == null ? '—' : record.reader.name) — RIGHT, though beyond the ruling's words; see ③.1. It is the validation path's own second unserviceable shape, judged by the one findTraversalConflicts, and the engine refuses it at plan time (hopConflict), so metadata that never met the lint reads null WITH the same sentence.

  7. Sort and search — RIGHT, and pre-existing. assertOrderByIsMaterializable (engine; present at the merge base, line 1822 there) throws INVALID_SORT 400 for a formula in orderBy on find and findOne; the REST door's assertSearchFieldsAreSearchable (protocol.ts line 13429 at the merge base) throws INVALID_FIELD 400 for a searchFields entry naming a virtual field; resolveSearchFieldResolution's auto-default admits textual and enum types only, so a formula is never in it, and isVirtualSearchField drops a declared one. The PR touches none of these files; it only pins the engine sort refusal. The ruling's third limb therefore resolves to "declared unsortable and unsearchable", written into the four describe strings; the ADR-0079 note is the follow-up Tier H PR, as the claim says.

  8. Public surface. @objectstack/objectql: collectReferenceTraversals and referenceTraversalConflicts are exported from the module file but from neither index.ts nor core.ts, and the package's exports map has . and ./core only, so they are NOT published; evaluateFormulaField keeps its signature. @objectstack/formula: findTraversalConflicts gains an optional fourth parameter subject, and TraversalSubject is a NEW exported type on the package index — an additive widening of a published surface. @objectstack/lint: no export moves. @objectstack/spec: four describe strings, no shape change; content/docs/references/ai/solution-blueprint.mdx is the regenerated output and matches them.

  9. The declaration. Clause-②: no (narrowing) is RIGHT on the narrowing limb and WRONG on the widening limb: by clause2-line.mjs's own table, a diff that widens one published surface (item 8, the formula index) and narrows another is yes (narrowing). No gate reads differently (the level axis stands down on no, and every moved package is already graded minor), and the tier served here is the higher one either way, so this is a declaration defect, not a contract defect: the adopting seat corrects the PR body line and .changeset/24197-formula-traversal-subject.md's line to Clause-②: yes (narrowing).

  10. What saved yesterday and is refused today (the narrowing an author meets): a formula expression that reads two hops, or a reference both bare and traversed. Before: the active save answered 200, every row read null, and one warn line named an evaluator fault (No such key). After: the active save answers 422 with the located prescription; a row stored before still reads null, with the lint's sentence in the warn instead of the evaluator's. Nothing that computed a value stops computing. In-repo census at the head: 37 formula builders across examples, platform objects, plugins and services, none reading through a reference (the eight display_title formulas compose stored ids); no example, seed or fixture is refused. content/docs/data-modeling/formulas.mdx line 104 already teaches the one-hop composed title record.customer.name, false at the merge base and true at this head; no doc teaches a two-hop formula.

  11. The flipped pin and the two gate-forced rows — RIGHT. relationship-traversal.test.ts's "checks nothing in the value role" asserted the old accept set and now asserts the refusal's code, params and wording plus the opted-out control; federated-injected-column-readers.test.ts renames three ledger keys to the moved function names with unchanged dispositions and adds the referenceTraversalConflicts :: referenceTargetOf() row; system-context.mdx row 29b moves its anchor to resolveOneHopRelated for the census gate.

② Semver level

Four changesets, read against the diff and scripts/check-changeset-no-major.mjs (launch window: a breaking change ships minor with a BREAKING carrier and an ADR-0087 marker; major refused). The Check Changeset check-run is success, which is CI's verdict.

  • @objectstack/formula minor — right. Additive public surface (an optional parameter, a new exported type) is at least minor; the opted-in value narrowing is breaking for opt-in callers only, carried by the feat(formula)!: summary and the adr-0087: not-required (no-migration-prescription) marker. "Without the flag, every role behaves as before" is true at this head.
  • @objectstack/lint minor — right. An accept-set narrowing (two refused shapes), breaking by the narrowing convention; ! summary plus marker; a "What to do" line present.
  • @objectstack/objectql minor — right. Public methods change behaviour (a hop computes; two shapes read null with a reason); no export moves; ! summary plus marker; "What to do" present. Its "a related field it may not see reads as empty" is accurate: an FLS-masked related column is materialised null by the loader.
  • @objectstack/spec patch — right. Describe strings only; no shape, key or export moves; it carries no Clause-② line, correctly, since it narrows nothing.

Correction owed on the declaration, not the level: the PR body and the formula changeset read Clause-②: no (narrowing); the honest reading is yes (narrowing) (①.9). No bump moves.

③ Boundary flags

  1. Refusing a reference read both bare and traversed (open question 1, shipped A). Answered: within the ruling's "as validation rules do". It is the validation path's own second shape, judged by the one findTraversalConflicts, and the null test an author writes today reads null anyway at the merge base (the traversal faulted on the stored id), so no working formula is removed. The prescribed record.reader.?name.orValue('—') is itself a one-hop read and is pinned on both seams. The ADR-0079 note must state the refusal; the carried adr_note_text does.

  2. Evaluating an empty reference (open question 2, shipped A). Answered: right. A dangling or hidden record is never evaluated, so the author's fallback cannot stand in for a record that is missing or withheld (pinned). One consequence to record, not to change: an FLS-masked related column is materialised null, so an optional read answers the author's fallback for it exactly as for an empty column — FLS's posture everywhere (masked reads as absent), disclosing nothing.

  3. Depth one and the cycle guard (open question 3, shipped A). Answered: right; the ruling fixes the depth, and the report's ablation showed non-termination without the guard. Mechanism note for the owning seat: the guard is a WeakSet keyed on the hop query object and read back inside find after foldEngineOptionAliases and lowerWhereFilterArray; both return the caller's own object when nothing folds (verified at the head: no alias key on the hop query, an object where, and copy-on-write comparand normalisation that returns the input unless a comparand is narrowed), so the identity holds at this head and the ping/pong pin guards it. A future normaliser that copies, or a numeric primary key whose string $in comparands get narrowed, would drop the marker silently, and the pin is the only thing that would catch it; a comment at the find call site naming the dependency is cheap.

  4. A member read through a non-reference field (open question 4, shipped A). Answered: right. referenceTargetOf is the arbiter on both seams, so record.address.city on an object-valued field is not planned as a hop and keeps traversing. Note: user and tree fields ARE references and traverse; the describe strings and the carried ADR note text say "lookup / master_detail" — an under-statement, not a false claim; the ADR note should say "a reference field (lookup, master_detail, user, tree)".

  5. The two gate-forced rows outside the claim. Both right and mechanical. Row 29b's prose still describes the predicate authority's elevation while its anchor now names a function carrying two authorities; the census gate checks the anchor only. Suggest the row name authority: 'predicate' in a later docs pass; not blocking.

  6. The carried adr_note_text. True of this code in every clause but one. "it is shown on every surface" is NOT true at this head: evaluateFormulaField (synchronous, preloads no hop; the PR names it as such) is what resolveRecordTitle (packages/objectql/src/record-title.ts) runs, and resolveRecordTitle is the hook-body title accessor ctx.title() in packages/runtime/src/sandbox/body-runner.ts. A composed title over lookups — the card's own producer case — answers undefined there while every REST read now shows it. ESCALATED: either the ADR note carves out the hook-body title accessor by name, or a card closes it (resolve the title over the engine's read, or hand the hook the record the read path already materialised). The report's "noted, not filed" is below Prime Directive chore: version packages #10 for a surface the ADR note would advertise; the adopting seat files it (dedupe words: ctx.title formula hop; resolveRecordTitle one hop; evaluateFormulaField traversal null).

  7. The out-of-scope finding: ?search=zzzz on an object whose fields are two lookups and a formula title answers every row. REAL at this head and INDEPENDENT of this PR. At the merge base, resolveSearchFieldResolution (packages/spec/src/data/search-fields.ts) auto-admits textual and enum types only, so lookup and formula are out; expandSearchToFilter (packages/objectql/src/search-filter.ts, if (searchFields.length === 0) return null) then hands expandSearchOnAst no filter, where is untouched and the term is dropped; the REST door's assertSearchFieldsAreSearchable fires only on an explicit searchFields, never on a bare search over an empty allowed set. Neither file is in this diff. A public-door wrong answer (class a); a dedupe over the titles of the 1,200 most recent issues found no card (A virtual formula field declared in searchableFields clears lint and the #4254 gate, then silently matches nothing — the same fail-open shape #4254 closed one axis over #6674 and metadata-protocol: the global search palette's searchAll renders titleFormat before nameField, the reverse of ADR-0079 and the spec's own titleFormat describe ("an explicit nameField now takes precedence") #24087 are neighbours, not duplicates). The adopting seat files it; the direction is the REST 读路径:searchFields / groupBy / aggregations 指向不存在的字段时被静默降级(#4226 收口后剩下的三条轴) #4254 posture: an unhonoured search is a 400, never a silent widening.

  8. Reviewer's own flags, none blocking:
    a. Log volume: a caller lacking read on the related object makes resolveOneHopRelated's catch log a warn on every page read (once per find per reference field), and that warn is not deduped the way reportFormulaFault is. Suggest treating a PERMISSION_DENIED as a quiet unreadable, or deduping per (object, field).
    b. FLS on the carrying object's own FK column does not stop the hop: formulas compute before the middleware post-phase, as for any own column a formula reads today, so a formula can show a related name whose FK the caller may not see. A pre-existing posture extended, not a new one; the control is FLS on the formula field itself. Worth one sentence in the ADR note.
    c. The hop read fires the related object's beforeFind and afterFind hooks and its middleware once per page, as the caller — expected, and auditable as the caller's own read.
    d. hydrateWriteFormulas has exactly two call sites (insert, update) at the merge base and at the head; bulk write responses computed no formula before and compute none now. No change; noted for completeness.
    e. Seven check-runs were in progress at read time (preamble); the adopting seat reads their conclusions before the queue.

Implemented-by: claude/issue-24197-formula-one-hop-read
Reviewed-by: session_01JtSQQ3EWsbNPzyYMug1tXy

VERDICT: PASS


Generated by Claude Code

Its published index widens by an optional findTraversalConflicts parameter
and the exported TraversalSubject type, beside the lint and engine narrowing.

Claude-Session: https://claude.ai/code/session_01M6VD9XhattUEHKD99C7KUR
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对 · domain:engine seat 1 · session_01JtSQQ3EWsbNPzyYMug1tXy · 2026-10-18T08:37Z · ACCEPT

Head: 7917005af.

Item Reading
Shape Draft PR → main. The body opens Part of #24197. The card stays open for two parts: the hook-body title accessor, and the ADR-0079 note (Tier H).
Scope 17 files (+1074 / −68), claim 6245950717 as re-declared in 6247243624 and 6247408205. The formula read path's one-hop preload (objectql/src/engine.ts) and the validation path's loader, shared as resolveOneHopRelated (rule-validator.ts). The value role's verdicts (packages/formula/src) and the lint's field-formula pass (validate-expressions.ts). Four blueprint describe strings (spec/src/ai/solution-blueprint.zod.ts), with their generated reference page. Three pin files and two gate-forced rows. Not governed (check-governed-merges.mjs --pr 24274: 0 of 17; 1142 changed lines, under the human-merge threshold).
Ruling The Ruled: line, limb by limb. (1) One hop is preloaded through a reference, one batched read per reference field per page, never per row; it uses the validation path's own mechanism, read as the caller. (2) The lint admits one hop and refuses two, plus a reference read both bare and traversed, which is the validation path's own second shape. (3) A formula is stored nowhere, so a title formula over lookups is declared unsortable (400 INVALID_SORT) and unsearchable (400 INVALID_FIELD). Those refusals already existed; they are now pinned and stated in the describe strings. The ADR-0079 note follows as its own Tier H PR.
As the caller A related row is read through the public find under the caller's context: RLS, the middleware, the FLS mask and the related object's hooks. A row the caller cannot read, a dangling id, or an undeclared field leaves the formula unevaluated: null plus a reason, never a fallback (pinned: the hidden reader's name is absent from the response). The validation path's behaviour is unmoved: the record diffs resolvePredicateRelated at the base against resolveOneHopRelated at the head, and the only changes are the authority switch, its context choice, the caller marker and the warn wording.
Review face Two faces: the published schema (the four describe strings) and the narrowing. The CONTRACT_REVIEW_TIER record 6247399985 on e9c02e6813 is PASS. The head moved by exactly one prose line: .changeset/24197-formula-traversal-subject.md's Clause-② (git diff --stat e9c02e6813 7917005af: 1 file, +1 / −1). A prose-only head move stays under that record.
Declaration Corrected to Clause-②: yes (narrowing) (record ①.9): the lint and engine narrow, and @objectstack/formula also widens its published index by one optional parameter and the exported type TraversalSubject. The claim line is re-declared in 6247408205. The seat patched the PR body's line to it through the relay (the dev's rule 3 hands PR-body edits to the seat); it reads back identical and readClause2Line reads yes / narrowing. On the dev's run, check-changeset-no-major and check-adr-0087-registration both exit 0. No level moves.
Prose face Four changesets. formula, lint and objectql are minor, each BREAKING with the adr-0087 not-required (no-migration-prescription) marker; spec is patch (describe strings only, no Clause-② line). Each sentence matches the diff. No sentence is false.
Pins and ablation Engine: 17 cases. They cover one book read and one reader read for four loans; paging; projection; findOne and write responses; RLS-hidden, FLS-masked, dangling, empty and undeclared cases; the second hop; and the cycle guard. Lint: 10 cases. The formula value-role pin flips to assert the refusal's code and wording. Six ablations each redden only their own cases; the restores are clean.
Gates Dev union at e9c02e6813: 117 derived, 117 run, 0 NOT MEASURED. Typecheck exit 0 for spec, formula, objectql (with check:test-typecheck) and lint. check:generated up to date. Live HTTP on examples/app-todo: a two-hop save answers 422; one hop reads Dune · Ann; ?sort=title answers 400 INVALID_SORT; ?search=…&searchFields=title answers 400 INVALID_FIELD.
CI At this read: 48 check-runs on 7917005af, 41 success, 7 skipped, 0 failed. The skips are the five path or opt-in skips (Console Pin Gate, the Dogfood matrix placeholder, Dogfood Verify CLI, Packed-tarball smoke, Temporal Conformance) plus the body-edit re-runs of Auto Label and Check PR Size, all on the EXPECTED_SKIPS roster. Test Core 1/6–6/6 and every Type Check lane are green, the seven runs the record left in progress.

Disposition:

  • open_questions: A on all four, as the record answers them. A reference read both bare and traversed is refused, with the optional-read prescription. An empty reference is evaluated, and a missing or hidden one never is. Depth is one with a cycle guard, so a related object's own hop is not served. A member read through a non-reference field is left alone.
  • deviations: accepted. The two gate-forced rows and the generated page are re-declared in 6247243624 and declared to domain:devx.
  • Escalated by the record and taken by the seat:
  • Named, nothing owed now: the per-page warn on a permission-denied hop (record ③.8a), and the WeakSet identity dependency at the hop's find call (③.3), both for the next touch of engine.ts.

Landing: ordinary queue (pr_ready + automerge_enable through the relay) once every check on this head is green or a rostered skip. The card stays open (Part of).


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 18, 2026 08:38
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 18, 2026 08:38
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 18, 2026
Merged via the queue into main with commit d537a31 Oct 18, 2026
49 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-24197-formula-one-hop-read branch October 18, 2026 09:11
os-project-manager pushed a commit that referenced this pull request Oct 18, 2026
…ference, and is not sortable or searchable

Records ruling 6245544505 (letter A on objectstack-ai/cloud#2925) as it
shipped in #24274 and #24307: the read path's one-hop preload under the
reader's permissions, the lint's two refusals, the hook-body title
accessor's channel, and the declared-unsortable answer with its remedy.
One appended section and one header pointer line; nothing above edited.

Claude-Session: https://claude.ai/code/session_01RVjnmZ6Jv6NcLDwow6RpNi
Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation protocol:ai size/xl tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants