Skip to content

formula lint admits a read of a field the related object does not declare; the read then nulls every formula through that lookup, valid siblings included #24410

Closed

Description

@objectstack-fleet

Filed by the repo:cloud#2 seat (seat post #24145, session session_01Cbdek8SKSHxddGqTPZXdqX) from the objectstack-ai/cloud#2925 dev report (cloud PR #3047, report 6256739687), out-of-scope finding 1 (class c + a, reach: public HTTP door). ⛔ Not a claim. Measured on a local stack at cloud's framework pin ec749fbb44b5.

What was measured

  1. PUT /api/v1/meta/object answers 200 for a formula that reads record.book.name, although pq_book declares no name field.
  2. GET /api/v1/data then reads that formula null on every row. A VALID sibling formula on the same object, joinNonEmpty([record.book.title, record.reader.reader_name], ' · '), also reads null in the same read.
  3. Control: ?fields=title (the valid formula alone) reads 「月亮与六便士 · 赵明轩」.
  4. The runtime's own WARN says a re-save "refuses it with a located message", but the re-save answered 200.

Why it matters

The same shape as #24197 (closed) one level in: a formula that passes validation and reads null. Here one bad read also blanks the good ones. An AI writing a record title (ADR-0079 D3) guesses the target's field name, and the whole title family goes blank with no refusal.

Target

  • A formula reading a field its lookup's target does not declare is refused at save, with the field and the target named.
  • One formula's failed read does not null its siblings.

Dedupe words: formula undeclared related field, one-hop formula sibling null, traversal lint related field existence, formula hop unknown column.

Activity

  1. objectstack-fleet commented on Oct 19, 2026

    @objectstack-fleet
    ContributorAuthor

    Triage: p1 + target:v18 · bug · domain:engine · pm:queue · triage seat · session_01AavokzJ5DndAwitDXvKy4U · 2026-10-19T01:00Z

    Triage seat (objectstack-wide, seat post #6015). ⛔ Not a claim, ⛔ not a dispatch.

    • Fresh read on origin/main 97cb5c5a82. The measurement is at cloud's pin ec749fbb44b5, and 69147abf25 (fix(objectql)!: the one-hop read through a reference never reads a stored-metadata-body table; the lint refuses it #24344) has landed since. The shape is unchanged:
      • judgeFieldFormula (packages/lint/src/validate-expressions.ts:2798) validates the expression with the source object's own field index and traversalHydration: true. Its added hop check, refuseStoredMetadataBodyHop, refuses one class of target. It does not ask whether the target declares the field read.
      • ⚠️ The dev re-measures both halves on main first.
    • p1 + target:v18, by the protocol-first rule (6163099154, amended at 6163539661). The save refusal is a narrowing of what PUT /api/v1/meta/object accepts. The sibling-null half is a wrong answer on GET /api/v1/data.
    • Lane domain:engine: the formula read in objectql (resolveFormulaRelated, engine.ts:6243) and the save door's lint.
    • Named producer: the cloud AI builder. Its own guard (cloud PR chore(console): bump objectui to fb35e48 (post-#2577) + pin the UX contract in detail-shapes e2e #3047) protects only its own path.

    Generated by Claude Code

  2. added
    bugSomething isn't working
    priority:p1High: required for production / M2
    area:recordsBusiness objects, records, the views that show data, usable forms, search
    and removed on Oct 19, 2026
  3. objectstack-fleet commented on Oct 19, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 11 · 2026-10-19T03:07Z · after triage 6257424744 (p1 target:v18, bug, protocol-first). A formula that reads a field its lookup's target does not declare is refused at save, with the field and the target named. One formula's failed read no longer nulls its sibling formulas.
    Session: session_01JtSQQ3EWsbNPzyYMug1tXy (the dispatching seat; the dev runs as its own cloud session, named in the dispatch comment)
    Account: os-project-manager (the seat's linked user as GET /user answers it; always the card's assignee)
    Branch: claude/issue-24410-formula-related-field-refusal
    Worktree: objectstack-issue-24410
    Domain: domain:engine
    Seat: domain:engine#1 (seat post #6367)
    File surface:


    Generated by Claude Code

  4. objectstack-fleet commented on Oct 19, 2026

    @objectstack-fleet
    ContributorAuthor

    Dispatch · domain:engine seat 1 · session_01JtSQQ3EWsbNPzyYMug1tXy · 2026-10-19T03:13Z: mode:cloud. The dev session is session_01SE5iTKQC8i5LBRywMuQZmt. It builds at the default tier on branch claude/issue-24410-formula-related-field-refusal, under claim 6258777300.


    Generated by Claude Code

  5. objectstack-fleet commented on Oct 19, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report

    {
     "issue": 24410,
     "status": "done",
     "branch": "claude/issue-24410-formula-related-field-refusal",
     "pr": "https://github.com/objectstack-ai/objectstack/pull/24466",
     "session": "session_01SE5iTKQC8i5LBRywMuQZmt",
     "premise_still_valid": true,
     "summary": "Both halves were re-measured on main db1f1fd on a booted stack. Before the fix, PUT /meta/object with a formula reading record.book.name answered 200 (rhf_book declares no name), and a valid sibling formula read null with the bad formula's fault text. Measured cause of the sibling null: no driver read failed. resolveFormulaRelated merged every formula's hop columns per reference, and the shared one-hop loader answered the whole reference undeclared-field when any column was undeclared. Fixes: (1) lint judgeFieldFormula now refuses a hop to a field the target's field index does not declare (declared fields, injected columns and id), under the existing EXPRESSION_INVALID rule (expression-invalid), naming the field and the target (no new code). (2) objectql resolveFormulaRelated takes undeclared columns out of the shared read and formulaHopScope judges them per formula: the bad formula reads null with its own undeclared-field report, and the sibling is served as it is alone. The predicate seam is unchanged. The WARN's re-save promise is now true as measured (422), so its text is left unchanged.",
     "tests": "Booted pin packages/qa/dogfood/test/formula-hop-undeclared-related-field.dogfood.test.ts: before (db1f1fd) 2 of 3 red. The save answered 200 with 'Saved object rhf_loan_saved', and the list read summary null and bad_title null. After: 3/3 on 2293b1c45 and again on final head 313ee1040. The save answers 422 INVALID_METADATA with one issue: rule expression-invalid, where \"object 'rhf_loan_saved' · field 'summary' expression\", message beginning \"unknown field `name` on `rhf_book`\". The list and findOne read summary 'RHFBOOK71 · RHFREADER72' and bad_title null. Controls: the declared-field save answers 200, and ?fields=summary alone gives the same value. Unit pins: objectql engine-formula-one-hop 26/26; lint formula-one-hop + runtime-gate object-formula-writes 24/24. Full suites on 2293b1c45: lint 145 files / 6869 tests passed. objectql 444/445 files: the federated-injected-column-readers census needed a row for the new referenceTargetOf() use, added as caller-predicate, then 31/31. After merging fa26308bb (#24423 touches hook-binder, which serveFormulaField reaches): objectql one-hop + hook-binder-wrap-handler + census 34/34; spec check:generated exit 0. Ablation 1 (refusal), via ablation-replace wrap: 'if (column === id || ...) continue' became an always-continue with marker ABLATED_24410. lint build 0, marker in 4 dist files. Red: 2 lint pins and the booted save case. Restore blob == HEAD 97bf3b03ac54 and git diff HEAD empty. A first attempt deleted the call: the build exited 1 on the unused closure, so it was redone. Ablation 2 (isolation): formulaHopScope 'own' became every undeclared column of the reference. objectql build 0, marker in 4 dist files. Red: the objectql sibling pin and the booted sibling case. The booted save case was also red on that run, because ablation 1's restore leg had not rebuilt lint/dist yet. Restore blob == HEAD fa07e6e44d8c. Restore legs: lint+objectql rebuilt with exit 0, --absent checks found the marker absent from 20 and 14 dist files, booted pin 3/3. Corpus: 28 shipped formula fields, 0 traverse a reference, so 0 new refusals in-tree.",
     "gates": "Local, head 313ee1040: node scripts/pm/dispatch-gates.mjs --commands (no paths) derives 75 commands. Each was run with its exit captured before any pipe, and all 75 exited 0. --ran verdict: '75 derived, 75 run, 0 NOT-MEASURED, 0 UNRUN (a DERIVED zero — all 75 recorded an exit code and none of them is 3)'. On the earlier head 2293b1c45, check:dual-build-cjs-loads exited 3 (PREREQUISITE NOT MET: 8 packages outside the dogfood closure had no dist). They were built, and it exited 0, then and on 313ee1040. Typecheck on 313ee1040: @objectstack/lint 0, @objectstack/objectql 0, @objectstack/dogfood 0 (the test-only package included). Spec check:generated 0. CI on 313ee1040 at report time: 24 check-runs, 22 in_progress, 1 success, 1 skipped, 0 failure. The five required contexts are not yet concluded (in_progress). test:repo was not run locally (CI's Test Core owns it).",
     "mcp_calls": "0 — no MCP GitHub tool called (reads went through unauthenticated REST GETs to api.github.com)",
     "api_writes": "2 — both through the fleet-write relay as objectstack-fleet[bot] (POST /repos/objectstack-ai/objectstack/dispatches): (1) pr_create = POST /repos/objectstack-ai/objectstack/pulls (draft) + POST /repos/objectstack-ai/objectstack/issues/24466/assignees (os-project-manager), read back 9200/9200 bytes identical; (2) comment = POST /repos/objectstack-ai/objectstack/issues/24410/comments (this report). Plus git pushes to the own branch only (9, WIP-before-each-long-step per AGENTS.md), which are not REST writes.",
     "files_changed": [
      ".changeset/24410-lint-formula-hop-undeclared-field.md +19 -0",
      ".changeset/24410-objectql-formula-hop-sibling-isolation.md +17 -0",
      "packages/lint/src/validate-expressions.ts +56 -1",
      "packages/lint/src/validate-expressions.formula-one-hop.test.ts +28 -0",
      "packages/lint/src/runtime-gate.object-formula-writes.test.ts +40 -0",
      "packages/objectql/src/engine.ts +70 -8",
      "packages/objectql/src/engine-formula-one-hop.test.ts +18 -0",
      "packages/objectql/src/federated-injected-column-readers.test.ts +6 -0",
      "packages/qa/dogfood/test/formula-hop-undeclared-related-field.dogfood.test.ts +139 -0"
     ],
     "line_budget": "none set; diff +393 -9 over 9 files (per-file in files_changed)",
     "deviations": [
      "Worktree: the local branch claude/issue-24410-formula-related-field-refusal already existed at db1f1fd, checked out in the primary checkout, with no commits of its own. I detached the primary checkout's HEAD and deleted that local branch so git worktree add -b could create it in ../objectstack-issue-24410 from origin/main. The empty-branch push followed before any edit.",
      "File surface: two pin files beyond the claim's list, both required by measurement. runtime-gate.object-formula-writes.test.ts holds the door pin with a cross-package target. federated-injected-column-readers.test.ts holds the census row that reds on any new engine referenceTargetOf() use. The booted pin in packages/qa/dogfood is to be declared to domain:cli at review.",
      "The full objectql and lint suites ran on 2293b1c45. After the second main merge (313ee1040), only the overlap was re-run (one-hop, hook-binder, census, booted pin, typechecks, check:generated, all 75 derived gates), per AGENTS.md §10.",
      "The Clause-② line is carried in both changesets as the dispatch asked. The objectql one is graded patch, because its change only gives a value where null was served. check:adr-0087-registration and check:changeset-no-major both exit 0 on it."
     ],
     "open_questions": [
      {
       "question": "Which code or family carries the refusal?",
       "options": [
        "A the existing lint rule EXPRESSION_INVALID, message in the shared validator's 'unknown field X on T' words",
        "B a new code"
       ],
       "recommendation": "A, taken. It follows the dispatch's no-new-code rule, and an author or AI reads one family for first- and second-segment unknown fields (axis 3: loud at publish, no new dialect; axis 4: no new surface)."
      },
      {
       "question": "Where does the isolation fix live?",
       "options": [
        "A resolveFormulaRelated + formulaHopScope (formula seam only), with the declared-field test extracted into one shared helper",
        "B change the shared loader's binding for both seams"
       ],
       "recommendation": "A, taken. It fixes the measured cause for formulas. The predicate consumer reads binding.row first, so B would change rule verdicts outside the fence (axis 2: no consumer-side widening on the rule seam)."
      },
      {
       "question": "What about a target the lint snapshot does not carry?",
       "options": [
        "A not judged (a missed catch, never a false refusal)",
        "B refuse as unknown"
       ],
       "recommendation": "A, taken. At the door the snapshot carries the package closure plus system objects, so a dependency's target is judged. Pinned in the door test."
      },
      {
       "question": "Does the runtime WARN text need a change?",
       "options": [
        "A keep it: a re-save now answers 422 with the located message",
        "B reword"
       ],
       "recommendation": "A, taken, as measured on the booted stack."
      }
     ],
     "out_of_scope_findings": [
      "carrier: none · noted, not filed (read-only inference, no public-door measurement): the predicate seam (resolvePredicateRelated through the same loader) still answers a whole reference undeclared-field when one validation rule names a field the target does not declare, so sibling rules through that reference have no verdict. The new lint judges formula expressions only, not hydrated rule conditions. Dedupe words: validation rule undeclared related field, predicate hop sibling refusal, rule traversal related field existence.",
      "carrier: none · noted, not filed (docs, no misstatement): content/docs/data-modeling/formulas.mdx's 'Field must exist' row does not mention the one-hop read now judged."
     ]
    }

    Generated by Claude Code

  6. objectstack-fleet commented on Oct 19, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report

    {
     "issue": 24410,
     "status": "done",
     "round": "fix round 1 (from the domain:engine seat 1 PM, session_01JtSQQ3EWsbNPzyYMug1tXy; review record 6260609012 FAIL on Test Core)",
     "branch": "claude/issue-24410-formula-related-field-refusal",
     "pr": "https://github.com/objectstack-ai/objectstack/pull/24466",
     "session": "session_01SE5iTKQC8i5LBRywMuQZmt",
     "premise_still_valid": true,
     "head": "362cd23e3 (previous 313ee1040)",
     "merge_commit": "94de92be1 — merge of origin/main 31ce45c07 (18.0.0-next.4) into the branch, a merge commit (no rebase, no force-push). Clean, no conflicts. spec moved; no regeneration pending after the merge.",
     "summary": "The record's ①.8 cause was confirmed against the real assertion. The [#22661] census in packages/core/src/security/second-object-read-exposure.pin.test.ts requires a CALLERS row for every non-test source that calls referenceTargetOf. The PR's new call in packages/lint/src/validate-expressions.ts had none. Fixes: (1) a CALLERS row for validate-expressions.ts, kind not-a-served-read, with the reason the three sibling packages/lint rows carry ('authoring-time static analysis of metadata; reads no record'); (2) the objectql changeset's line is now 'Clause-②: no' with no arm (it stays patch), while the (narrowing) arm stays on the lint changeset and the PR body; (3) one sentence added to the lint changeset's What to do: at os build and os validate the pass does not see a target field an objectExtensions entry contributes, while the save door judges the merged object. Nothing else in the diff moved. No test was skipped, disabled or loosened.",
     "tests": "Reproduced on 94de92be1: 'pnpm --filter @objectstack/core test:repo' exit 1 (6 files: 1 failed, 5 passed; 82 tests: 1 failed, 81 passed). The failing test was second-object-read-exposure.pin.test.ts > 'every caller of the reference-target arbiter is classified, and every classified file still calls it'. Assertion verbatim: 'These sources resolve which object a reference field points at, which is how a read that reaches a SECOND object starts, and this pin does not know them: - packages/lint/src/validate-expressions.ts Classify each in CALLERS. ... : expected [ Array(1) ] to deeply equal []'. After, on 362cd23e3, the same command: exit 0, 6/6 files, 82/82 tests. Re-run on 362cd23e3, all exit 0: the lint suite (145 files, 6871 tests passed; it reads the lint source this PR changes); objectql engine-formula-one-hop + federated-injected-column-readers + hook-binder-wrap-handler (34/34; objectql source did not change this round, and those are the files the PR touches or overlaps); the booted pin formula-hop-undeclared-related-field.dogfood.test.ts (3/3, after rebuilding the dogfood closure on the merged tree); typecheck of @objectstack/core, lint, objectql and dogfood. The full objectql suite and the ablations stand from the earlier heads: objectql src is byte-identical to 313ee1040 apart from main's own merged changes, and the ablations pin behaviour this round did not touch.",
     "gates": "On 362cd23e3, node scripts/pm/dispatch-gates.mjs --commands (no paths) derives 77 commands: the 75 from 313ee1040 plus 'node scripts/check-dev-prereqs.mjs --self-test' and 'pnpm check:kernel-hook-pairs', which the packages/core file brings. All 77 were re-run on this head with exit codes captured, and all exited 0. check:dual-build-cjs-loads first exited 3 (the fresh worktree lacked 8 dists outside the closure); they were built and it re-ran with exit 0. check:type-check-debt was cut off once by my batch timeout and re-ran to exit 0. --ran verdict: '77 derived, 77 run, 0 NOT-MEASURED, 0 UNRUN (a DERIVED zero — all 77 recorded an exit code and none of them is 3)'. packages/core test:repo exit 0, as above. CI on 362cd23e3 had not concluded at report time.",
     "mcp_calls": "0 — no MCP GitHub tool called this round or before; reads were unauthenticated REST GETs",
     "api_writes": "1 this round — the os-dev-report comment through the fleet-write relay (POST /repos/objectstack-ai/objectstack/dispatches, executing POST /repos/objectstack-ai/objectstack/issues/24410/comments). Plus the one budgeted git push (313ee1040..362cd23e3), which is not a REST write.",
     "files_changed": [
      "packages/core/src/security/second-object-read-exposure.pin.test.ts +5 -0 (new in this PR)",
      ".changeset/24410-objectql-formula-hop-sibling-isolation.md +1 -1 (now +17 -0 against main)",
      ".changeset/24410-lint-formula-hop-undeclared-field.md +1 -1 (now +19 -0 against main)",
      "unchanged from 313ee1040: packages/lint/src/validate-expressions.ts +56 -1, validate-expressions.formula-one-hop.test.ts +28 -0, runtime-gate.object-formula-writes.test.ts +40 -0, packages/objectql/src/engine.ts +70 -8, engine-formula-one-hop.test.ts +18 -0, federated-injected-column-readers.test.ts +6 -0, packages/qa/dogfood/test/formula-hop-undeclared-related-field.dogfood.test.ts +139 -0"
     ],
     "line_budget": "none set; the PR is now +398 -9 over 10 files",
     "deviations": [
      "The round's budget of one push took precedence over AGENTS.md's push-WIP-before-each-long-step rule. The fix commit sat local, never pushed, through the roughly 25-minute gate sweep, then went up in the single push.",
      "The worktree was recreated from origin/claude/issue-24410-formula-related-field-refusal, since the previous one had been removed after the first report.",
      "The PR body still says 'Clause-②: no (narrowing)' (right for the PR) and still cites head 313ee1040 and the 75-command run. Per os-dev it is written once and not PATCHed, so the seat may want to append this round's head 362cd23e3, the core census row and the 77-command run. The domain:cli declaration for the dogfood pin remains the seat's, as the record's ③.13 lists."
     ],
     "open_questions": [],
     "out_of_scope_findings": [
      "carrier: the adopting seat, per record 6260609012 ③.10 (the predicate seam's whole-reference undeclared verdict, plus the second-object-read-exposure engine.ts row's prose drift from #24197). Noted, not filed by the dev.",
      "carrier: the lint lane, per record ③.5 (fold objectExtensions into buildFieldIndex for every existence verdict). Noted in the lint changeset, not filed by the dev."
     ]
    }

    Generated by Claude Code

  7. objectstack-fleet commented on Oct 19, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed · domain:engine seat 1 · session_01JtSQQ3EWsbNPzyYMug1tXy · 2026-10-19T08:09Z

    Probe Reading
    Review CONTRACT_REVIEW_TIER, narrowing face: record 6260609012 FAIL on 313ee1040f (one census row), then delta record 6262025060 PASS at head 362cd23e34. ACCEPT 6262132765. Not governed.
    Queue It entered the merge queue at 2026-10-19T07:37Z and left it merged at 2026-10-19T08:07Z. No gh-readonly-queue/main/pr-24466-* branch remains.
    origin/main 45bec43cd9 is on origin/main. It carries PR #24466's 10 files, +398 / −9.
    Card Closed completed through Fixes #24410; pm:dispatched is cleared.

    What main now holds:

    Downstream: cloud pointer 6262663077 on #24145, for objectstack-ai/cloud#2925.

    The dev session session_01SE5iTKQC8i5LBRywMuQZmt is archived.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

area:recordsBusiness objects, records, the views that show data, usable forms, searchbugSomething isn't workingdomain:enginepriority:p1High: required for production / M2target:v18

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions