Repository navigation
feat(objectql,lint): a formula field reads one hop through a reference — record.book.title computes, a second hop is refused - #24274
Conversation
…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>
…e-hop loader 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>
…rmula-one-hop-read
📓 Docs Drift CheckThis PR changes 4 package(s): 20 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 8 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 145 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # 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
|
Contract reviewServed-tier: Inputs: card #24197 (its body with the 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 judgmentsJudged against the
② Semver levelFour changesets, read against the diff and
Correction owed on the declaration, not the level: the PR body and the formula changeset read ③ Boundary flags
Implemented-by: 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>
席位核对 ·
|
| 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 todomain:devx.- Escalated by the record and taken by the seat:
- (③.6) The hook-body title accessor
ctx.title()(resolveRecordTitle→ the synchronousevaluateFormulaField) serves no hop, so a composed title over lookups answersundefinedthere. It is the card's next part, before the ADR-0079 note. The note will then also say "a reference field (lookup, master_detail, user, tree)" and carry one sentence on FLS of the carrying FK column (record ③.4 and ③.8b). - (③.7) The out-of-scope
?search=finding is filed as metadata-protocol / objectql: asearchterm on an object with no searchable field is dropped silently, so the read answers every row (an empty auto-default search set is not refused) #24284.
- (③.6) The hook-body title accessor
- Named, nothing owed now: the per-page warn on a permission-denied hop (record ③.8a), and the
WeakSetidentity dependency at the hop'sfindcall (③.3), both for the next touch ofengine.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
…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>
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/formulaalso widens its published index by one optional parameter and one exported type,TraversalSubject. Perclause2-line.mjs, a diff that widens one surface and narrows another readsyes (narrowing). No level moves.)What changes
A formula field that reads through a reference field (
record.book.title) now computes. Before,planFormulaProjectiongave a formula only the row's own columns, so the reference held an id, the read faulted, and the field readnullon every row. Meanwhile the lint admitted the expression.Engine (
packages/objectql).find,findOne, and the record a create or update hands back. The read is one batchedfindper reference field per page, never one per row, and it names onlyidand the related fields the formulas name.resolvePredicateRelatedbody moved intoresolveOneHopRelated(schema, wanted, rows, context, authority), and both seams call it. Validation predicates keepauthority: 'predicate': a SYSTEM read behind the caller's read doors, unchanged. Formulas useauthority: 'caller': the hop is read under the reader's own context, through every layerfindapplies. The hop collectoraddReferenceTraversalsand the conflict checkreferenceTraversalConflictsare extracted fromrule-validator.ts, not re-implemented.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 readsnull, and the formula fault sink reports why, naming the related object and field. An empty reference is evaluated, sorecord.reader.?name.orValue('—')gives it a value. A plain read through an empty reference faults, and the reported reason names that optional read.nullwith the lint's own sentence. Afindthat IS a formula's hop read (tracked by query-object identity in aWeakSet, 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.judgeFieldFormulapassestraversalHydration: true.validateExpressionnow honours the flag in thevaluerole, andfindTraversalConflictstakes asubject('predicate'default, or'formula') for the wording. Results:record.book.title + ' · ' + record.reader.namepasses.record.book.shelf.codeis refused (traversal-multi-hop), naming the field and the one-hop alternative.record.reader == null ? … : record.reader.nameis refused (traversal-bare-and-traversed), with the optional read as the repair.Spec (
packages/spec). The blueprint schema'sexpressionandnameFielddescribe strings, and their strict twins, gave"record.order_no + ' · ' + record.customer"and"{order_no} · {customer}".customeris a lookup, so those compose the stored id into the title. They now teachrecord.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.mdxis 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.mdxrow 29b: the anchor moves fromengine.ts#resolvePredicateRelatedtoengine.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=titleanswers400 INVALID_SORTand?search=…&searchFields=titleanswers400 INVALID_FIELD. The engine-level sort refusal is pinned inengine-formula-one-hop.test.ts. No engine change was needed. The describe strings now say so.Verification
Head
e9c02e6813(after mergingorigin/mainat53ef752436).Live backend over HTTP.
examples/app-todowas booted withobjectstack dev --freshon this branch. A junction object was saved throughPUT /api/v1/meta/object/lib_loan, with lookupsbookandreaderandnameField: title, wheretitle = record.book.title + ' · ' + record.reader.name.lib_loanwithtitle = record.book.shelf.name(two hops)422 INVALID_METADATA,object 'lib_loan' · field 'title' expression: "record.bookis read through more than one relationship hop. A formula resolves ONE hop …"lib_loanwith the one-hop composed title200POST /api/v1/data/lib_loan{book, reader}record.title=Dune · BoGET /api/v1/data/lib_loan?fields=title,book,readerDune · Ann,Emma · Ann,Dune · Bo, andnullfor the loan with an emptyreader. Rows still carry the foreign-key ids. The empty-reader fault is logged once, with the optional-read repair.GET …?sort=title400 INVALID_SORTGET …?search=Dune&searchFields=title400 INVALID_FIELDEngine pins (
packages/objectql/src/engine-formula-one-hop.test.ts, 17 tests, realObjectQLand a driver that pages):lib_bookread and 1lib_readerread. The book read iswhere id $in [b1,b2,b3]with fields[id, title]. The next page reads only its own ids.titleanswers{ title }alone.findOneand the insert response are served.lib_readerrows for non-system callers leaves the formulanull, 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 maskingnamekeeps it out of the value.nullwith a reason (no fallback evaluated). An empty reference with.?name.orValue('—')givesEmma · —. An undeclared related field is named on the related object.orderBy: titlerejects 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 withcustomera lookup; thenameFieldstrings state the sort and search answer. Formula pins (relationship-traversal.test.ts): thevaluerole 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 thevaluerole" asserted that an opted-invaluesource earned no traversal refusal. It now asserts the refusal'scode,paramsand formula wording.Ablations (
scripts/ablation-replace.mjs; each mutation proven on disk, each restore provenblob == HEAD,git diff HEADempty):resolveFormulaRelatedreturns no lookupformulaHopReads.addremovedhopConflictnever settraversalHydrationdropped fromjudgeFieldFormulavaluearmpredicateonlySuites 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 testran 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.typecheckpassed (exit 0) forspec,formula,objectql(itscheck:test-typecheckincluded) andlint.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 one9c02e6813. All 117 ran, and every one exited 0. The--ranreconciliation: "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 yieldsnullthere, as acan()formula already does. Its docblock says so.record.address.cityon an object-valued field traverses today, and the validation path leaves it alone the same way.GET /api/v1/data/lib_loan?search=zzzz-nothinganswers200with 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.Generated by Claude Code