Skip to content

fix(lint,objectql)!: a formula reading a field its lookup target does not declare is refused at save, and no longer nulls its sibling formulas - #24466

Merged
objectstack-fleet[bot] merged 11 commits into
mainfrom
claude/issue-24410-formula-related-field-refusal
Oct 19, 2026
Merged

objectstack-fleet[bot] merged 11 commits into
mainfrom
claude/issue-24410-formula-related-field-refusal

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #24410

Clause-②: no (narrowing) (the save door newly refuses a formula it accepted; owes one CONTRACT_REVIEW_TIER review, on the narrowing face, before the queue)

Dispatched by the domain:engine seat 1 PM (claim 6258777300). Developer session: session_01SE5iTKQC8i5LBRywMuQZmt.

What changes

Save refusal (lint, judgeFieldFormula). The field-formula pass now judges the field a formula reads one hop through a reference against the target object's field index: its declared fields, the system columns the platform injects on it, and id. A read of a field the target does not declare is refused under the existing EXPRESSION_INVALID rule (expression-invalid). No new code. The message opens in the shared validator's unknown field words and names the field and the target: "unknown field name on rhf_book: this formula reads it through the reference record.book ...". The same pass runs for os validate, os build and the object save door.

  • At the save door the snapshot carries the live registry's objects in the written package's closure. So a target from a package the written one depends on is judged, and a system object is always judged.
  • A target outside the snapshot is not judged. That is a missed catch, never a false refusal.
  • A hop into the stored-metadata-body family stays with refuseStoredMetadataBodyHop and is not judged twice.

Sibling isolation (objectql, resolveFormulaRelated). Measured cause: no driver read failed. The formula read merged every formula's hop columns per reference. The shared one-hop loader then answered the whole reference undeclared-field when any column was undeclared. So the valid sibling read null and reported the bad formula's field: "the formula reads 'name' through book". The fix:

  • resolveFormulaRelated takes the undeclared columns out of the shared read.
  • formulaHopScope judges them per formula.
  • A bad formula reads null with its existing undeclared-field fault report, which now names only its own columns.
  • A valid sibling is served from the same single read, exactly as it is alone.
  • The predicate seam's use of the loader is byte-for-byte unchanged. The declared-field test moved into one helper, undeclaredRelatedFields, that both seams share.

The WARN text. "a re-save of the object, refuses it with a located message" now holds for this fault: a re-save of the object answers 422 with the located refusal (measured below). Left unchanged.

⛔ No change to which records a formula may read: the #24316 / #24358 family guards are untouched.

Verification (M, booted composition: bootStack, real ObjectQL, SQL driver, REST, auth, metadata)

Pin: packages/qa/dogfood/test/formula-hop-undeclared-related-field.dogfood.test.ts. The fixture is rhf_book (title), rhf_reader (reader_name) and rhf_loan. The loan has a valid summary formula (joinNonEmpty([record.book.title, record.reader.reader_name], ' · ')) and a code-declared bad bad_title (record.book.name).

Measurement Before (main db1f1fd) After (head)
PUT /meta/object/rhf_loan_saved with record.book.name 200 Saved object 'rhf_loan_saved' 422 INVALID_METADATA, one issue: rule expression-invalid, where object 'rhf_loan_saved' · field 'summary' expression, message unknown field \name` on `rhf_book` ...`
Control: the same save with record.book.title 200 200
Control: GET /data/rhf_loan?fields=summary (valid alone) RHFBOOK71 · RHFREADER72 RHFBOOK71 · RHFREADER72
GET /data/rhf_loan: summary / bad_title null / null, and WARN on summary: "reads 'name' through book" RHFBOOK71 · RHFREADER72 / null, with WARN on bad_title only
GET /data/rhf_loan/:id: summary / bad_title null / null value / null

Unit pins:

  • engine-formula-one-hop.test.ts: the sibling is served as alone, ONE book read with fields id,title, and only the bad formula reports.
  • validate-expressions.formula-one-hop.test.ts: the refusal names the field and the target. id and an injected column (created_at) are admitted. A target the stack does not carry is not judged.
  • runtime-gate.object-formula-writes.test.ts: at the door, the target comes from another package the written one depends on. The refusal is LIT and the declared-field read is the CONTROL.

Ablations (scripts/ablation-replace.mjs wrap form, then scripts/ablation-dist-preflight.mjs). Each restore was checked against the HEAD blob with an empty git diff HEAD.

  • Refusal. In refuseUndeclaredHopField, if (column === 'id' || ...) continue; became an always-continue carrying the marker ABLATED_24410.
    • lint build exit 0. Marker present in 4 dist/ files.
    • Red: 2 lint pins (refuses a read of a field the target does not declare, the door LIT), and the booted save case.
    • The first attempt deleted the call instead. The lint build then exited 1 on the unused closure, so it was redone. The pins were red on that attempt too.
  • Isolation. In formulaHopScope, own became every undeclared column of the reference, not this formula's own.
    • objectql build exit 0. Marker in 4 dist/ files.
    • Red: the objectql sibling pin and the booted sibling case.
    • The booted save case was also red on this run, because ablation 1's restore had not rebuilt lint/dist yet. That redness belongs to ablation 1.
  • Restore legs. lint and objectql rebuilt (exit 0). --absent checks: marker absent from all 20 lint and all 14 objectql built files. Booted pin 3/3.

Corpus. No formula field the repository ships reads through a reference: 28 formula fields across examples (app-crm, app-showcase, app-todo) and platform display titles. So the pass refuses nothing it accepted in-tree.

Tests and gates

The full package suites ran on 2293b1c45, after merging origin/main 9112bcd88:

  • pnpm --filter @objectstack/lint exec vitest run: 145 files, 6869 tests passed.
  • pnpm --filter @objectstack/objectql exec vitest run: 444/445 files passed. The one red was the federated-reader census, which needed a row for the new referenceTargetOf() use. Row added (caller-predicate); that file plus the one-hop file then passed 31/31.

Then origin/main fa26308bb was merged in (head 313ee1040). It brings #24423, which touches objectql/hook-binder.ts: the hook body's ctx.title, which serveFormulaField → resolveFormulaRelated serves. It also moves packages/spec. On 313ee1040:

  • The closure was rebuilt.
  • typecheck: @objectstack/lint 0, @objectstack/objectql 0, @objectstack/dogfood 0.
  • pnpm --filter @objectstack/spec check:generated: exit 0.
  • objectql one-hop, hook-binder wrap-handler and census files: 34/34 passed.
  • Booted pin: 3/3.

Derived gates: node scripts/pm/dispatch-gates.mjs --commands (no paths) on 313ee1040 gives 75 commands. Each was run with its exit code recorded, and all 75 exited 0.

  • check:dual-build-cjs-loads exited 3 on the earlier head (8 packages outside the dogfood closure had no dist/). They were built, and it exits 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)".

Diff (+/−)

File + −
.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

File surface: within the claim, plus two pin files the measurement required.

  • 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. It reds on any new engine referenceTargetOf() use.
  • The booted pin lives in packages/qa/dogfood, to be declared to domain:cli at review.

Acceptance notes

  • The predicate seam has the same union verdict (read-only inference, not measured at a door). resolvePredicateRelated hands the shared loader every rule's hop columns per reference. So one validation rule naming a field its target does not declare leaves every rule through that reference without a verdict. The lint added here judges formula expressions only, not hydrated rule conditions. The fence leaves that seam unchanged. Dedupe words: validation rule undeclared related field, predicate hop sibling refusal, rule traversal related field existence.
  • content/docs/data-modeling/formulas.mdx's "Field must exist" row does not mention the one-hop read. It does not misstate the new behaviour, so it was left alone.

Generated by Claude Code

…ot declare (wip, red on main)

Claude-Session: https://claude.ai/code/session_01SE5iTKQC8i5LBRywMuQZmt
Co-authored-by: Claude <noreply@anthropic.com>
… its siblings

The formula read merged every formula's hop columns per reference and the
shared loader answered the whole reference undeclared-field when any column
was undeclared, so a valid sibling reading through the same lookup read null
with the bad formula's fault. The undeclared columns now leave the shared read
and are judged per formula.

Claude-Session: https://claude.ai/code/session_01SE5iTKQC8i5LBRywMuQZmt
Co-authored-by: Claude <noreply@anthropic.com>
…ot declare

judgeFieldFormula validated the expression against the source object only, so
record.book.name saved with a 200 when the book declares no name, and the
formula then read null on every row. The field-formula pass now refuses it
under EXPRESSION_INVALID, naming the field and the target, at the build and
at the object save door alike.

Claude-Session: https://claude.ai/code/session_01SE5iTKQC8i5LBRywMuQZmt
Co-authored-by: Claude <noreply@anthropic.com>
…ormula hop field

Claude-Session: https://claude.ai/code/session_01SE5iTKQC8i5LBRywMuQZmt
Co-authored-by: Claude <noreply@anthropic.com>
…ormula hop to an undeclared field

Claude-Session: https://claude.ai/code/session_01SE5iTKQC8i5LBRywMuQZmt
Co-authored-by: Claude <noreply@anthropic.com>
… receivers

The #5017 receiver scan reads every member access in the rule's source; the
closure now walks the object's fields through the receivers the scan already
tables instead of adding new ones.

Claude-Session: https://claude.ai/code/session_01SE5iTKQC8i5LBRywMuQZmt
Co-authored-by: Claude <noreply@anthropic.com>
…ederated disposition

Claude-Session: https://claude.ai/code/session_01SE5iTKQC8i5LBRywMuQZmt
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Oct 19, 2026
@github-actions

github-actions Bot commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/lint, @objectstack/objectql, touching 7 documentable anchor(s).

1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/permissions/system-context.mdx (via resolveOneHopRelated (symbol, a method of class ObjectQL))
What this run could not see
  • 2 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 — 21 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 fb9c22aa0929e19ae76707cb55b5f24fead2d9fe → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 868f4c236c858e577478b59d290e864683de64d9 — the merge of head 362cd23e347656b16b605a7af60b5aeb88706e3e into base fb9c22aa0929e19ae76707cb55b5f24fead2d9fe, 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 868f4c236c858e577478b59d290e864683de64d9 && git checkout 868f4c236c858e577478b59d290e864683de64d9
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin fb9c22aa0929e19ae76707cb55b5f24fead2d9fe 362cd23e347656b16b605a7af60b5aeb88706e3e && git checkout -B drift-repro fb9c22aa0929e19ae76707cb55b5f24fead2d9fe && git merge --no-ff 362cd23e347656b16b605a7af60b5aeb88706e3e

node scripts/docs-audit/affected-docs.mjs --json fb9c22aa0929e19ae76707cb55b5f24fead2d9fe

⚠️ 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 fb9c22aa0929e19ae76707cb55b5f24fead2d9fe → 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

CI red on 313ee1040f · domain:engine seat 1 · session_01JtSQQ3EWsbNPzyYMug1tXy · 2026-10-19T05:36Z

  • Failing: Test Core (2/6) (run 39203308364, job 117900776928), in the step "Run this shard's tests". The annotation names packages/core's pnpm run test:repo (exit 1). The turbo tasks on the shard all passed (62/62), and check-test-completeness accounted for every declared test.
  • This PR's, not the base's: Test Core (2/6) is green on each of the last eight main commits, 725a5b1311 through ec951e29e4. This PR edits no packages/core file, but test:repo holds the six core pins that read sources outside the package (vitest.repo-tests.json). Among them are row-serving-door-exposure.pin.test.ts and second-object-read-exposure.pin.test.ts, which both scan packages/objectql/src/engine.ts. The likeliest path is a scan over resolveFormulaRelated's new referenceTargetOf / _registry.getObject read. That is not measured: the job log's download is refused from this seat, so the assertion is not in hand.
  • Next: the dev reproduces with pnpm --filter @objectstack/core test:repo on the merged tree, root-causes it, and pushes a fix inside this PR's surface. It is queued for the seat's next free dev slot (concurrency cap 3). The contract review on this head goes on; a code fix gets a delta check before the queue.

Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 313ee1040f6fad2fbb8e037219e47aefdd7a83dc
Local-runs: none
Scope: .changeset/24410-lint-formula-hop-undeclared-field.md, .changeset/24410-objectql-formula-hop-sibling-isolation.md, packages/lint/src/validate-expressions.ts, packages/objectql/src/engine.ts, packages/lint/src/validate-expressions.formula-one-hop.test.ts, packages/lint/src/runtime-gate.object-formula-writes.test.ts, packages/objectql/src/engine-formula-one-hop.test.ts, packages/objectql/src/federated-injected-column-readers.test.ts, packages/qa/dogfood/test/formula-hop-undeclared-related-field.dogfood.test.ts + packages/lint/src/system-fields.ts (injectedColumnsFor), packages/lint/src/runtime-gate.ts (narrowObjectsToPackageClosure, buildRuntimeWriteSnapshots, the stored pass), packages/lint/src/authoring-rules.ts (the one body both doors run), packages/metadata-protocol/src/runtime-authoring-gate.ts and packages/metadata-protocol/src/protocol.ts (assertRuntimeAuthoringRules: the door's object universe), packages/spec/src/data/field-value.zod.ts (referenceTargetOf, REFERENCE_VALUE_TYPES, the implicit user target), packages/spec/src/data/injected-system-columns.ts (resolveInjectedSystemColumns), packages/objectql/src/registry.ts (resolveObject/listItems, applySystemFields), packages/objectql/src/validation/rule-validator.ts (addReferenceTraversals), packages/formula/src/validate.ts (the unknown field words), packages/core/src/security/second-object-read-exposure.pin.test.ts and packages/core/vitest.repo-tests.json (the [#22661] arbiter census that test:repo runs), content/docs/data-modeling/formulas.mdx, scripts/pm/clause2-line.mjs, scripts/check-adr-0087-registration.mjs, scripts/check-changeset-no-major.mjs, and the in-tree formula corpus (examples/*/src/objects, packages/platform-objects, packages/plugins)

Inputs: card #24410 (body; triage 6257424744, claim 6258777300, dispatch 6258848224, the domain:spec declarations 6258789472 on #6017 and 6258795887 on #18549, the dev report 6260190420, all read as claims), the precedent #24197 / PR #24274 record 6247399985, PR #24466 (body, the 9-file list), the net diff fa26308bb0..313ee1040f (merge base fa26308bb0, +393/−9, read with git diff and git show; nothing checked out), and the head's check-runs.

Check-runs on the head, final read 2026-10-19T05:42:58Z: 41 runs, none in progress. 33 success, among them Check Changeset, Governed Surface Queue Guard, Build Core, every Lint job, Type Check source/consumer/debt/workspace, Spec property liveness, Dogfood Regression Gate, the three claim and card guards, and Test Core shards 1, 3, 4, 5 and 6. 6 skipped by design. 2 failure: Test Core (2/6) (completed 05:33:30Z) and the aggregate Test Core (05:41:46Z). The failed step is "Run this shard's tests"; the annotation names the command: packages/core pnpm run test:repo exited 1. At my first read (05:26:59Z) shards 2–6 were still in progress; the dev's report (05:13Z) saw 22 in progress and no failure. origin/main's current head 95246668d7 has Test Core (2/6) success (05:14:13Z), so the red is not inherited. The job log is behind a host this session cannot reach (proxy 403); the cause below is read off the pin's own source against the diff, not off the log.

① Derived judgments

  1. The refusal, as implemented (refuseUndeclaredHopField, validate-expressions.ts 1962–2003, called from judgeFieldFormula after refuseStoredMetadataBodyHop) — RIGHT in mechanism. For each record.FIELD.COLUMN traversal the analyser reports, it takes the carrying object's authored field FIELD (fieldEntries(obj)), asks referenceTargetOf(def) for the target, skips a stored-metadata-body target (owned by refuseStoredMetadataBodyHop, not judged twice) and a target fieldIndex has no entry for, then refuses each column that is neither id nor in fieldIndex.get(target). "Declared" is therefore buildFieldIndex's set: the target's authored field names (either fields shape) plus injectedColumnsFor(obj) = resolveInjectedSystemColumns(obj).names (the primary key, organization_id when tenancy applies, the audit provenance family, owner_id / owning_business_unit_id by ownership tier), plus id again. The runtime loader's "declared" (undeclaredRelatedFields, engine.ts 2677) is the registry object's fields keys plus id, and applySystemFields (registry.ts 526) spreads exactly that plan's columns into fields. The two sets coincide, so the lint refuses only what the loader would answer undeclared-field — the set of formulas that read null on every row today.

  2. Can a formula that works today be refused? Judged per case:

    • an injected column on the target (record.book.created_at, record.book.owner_id): admitted — pinned (created_at) and derived from the same plan the registry injects. One over-inclusion in the safe direction: the lint derives without the deployment's platformGlobalObjects, so organization_id stays admitted on a platform-global target where the runtime does not inject it; a missed catch, never a refusal;
    • a field contributed by an extend contributor or an objectExtensions body: at the save door, admitted. The door's universe is registry.listItems('object') → getAllObjects → resolveObject(fqn), the fold of every extender over the base (registry.ts 4170–4262), narrowed by narrowObjectsToPackageClosure (runtime-gate.ts 416) to system objects, unpackaged overlay rows, the written package and its declared dependencies; the written item replaces its stored self (buildRuntimeWriteSnapshots). The target's field map at the door is the same merged map the loader reads. At os build / os validate the lint folds nothing (no objectExtensions handling in validate-expressions.ts, none in the CLI's stack assembly), so in a multi-package stack that carries BOTH the target's owner object AND an extension adding the field, a formula reading that extended field is refused at build although it computes at runtime. This is the same gap the first-segment existence check already has (a formula reading its own object's extension-contributed field is "unknown field" at build today), now extended one hop; the lint's own ledger states the stance ("Object extensions are not judged", validate-field-consumers.ts 167). Narrow, build-time only, not the card's door. Flagged in ③.5, not blocking;
    • a target declared in another package of the closure: judged at the door (limb 4), pinned (runtime-gate.object-formula-writes.test.ts, fx_book from com.example.library); at build, carried only if the stack carries it, else not judged;
    • a system object target: carried at the door unconditionally (limb 1); sys_user declares name, email, image and the rest (sys-user.object.ts 729 ff.), so record.assignee.name through a user field (implicit target sys_user, IMPLICIT_REFERENCE_TARGETS) is admitted;
    • a target the snapshot does not carry: not judged (fieldIndex.has(target) false) — pinned (hop_elsewhere). Right: a missed catch, never a false refusal;
    • reference by short name: judged; by FQN: fieldIndex keys on obj.name, so an FQN-spelled reference is not judged (missed catch). The spelling of the carrier (reference vs legacy) is referenceCarrierOf's, shared with the runtime;
    • case or label forms of a column: refused, exactly as the loader's n in declared refuses them at runtime — nothing working is lost;
    • the hop FIELD itself an injected lookup (record.owner_id.name, record.created_by.email): fieldEntries(obj) lists authored fields only, so not judged — a missed catch (the hop works at runtime: the registry's owner_id def is a lookup to sys_user). Not a refusal;
    • a tree self-reference with no reference carrier: referenceTargetOf answers undefined before the registry materialises it, so not judged at build and judged against the object's own index at the door. Not a refusal;
    • a field typed other than lookup / master_detail / user / tree (REFERENCE_VALUE_TYPES): referenceTargetOf answers undefined, not judged, and the runtime plans no hop for it either.
      Net: the save door newly refuses exactly the formulas whose hop column the loader would mark undeclared-field; every refused formula reads null on every row at the merge base. No formula that computes a value is refused at the door.
  3. Code and message — RIGHT. No new code: the issue goes into the field-formula pass's issues list and surfaces under the existing EXPRESSION_INVALID rule (expression-invalid); the dogfood pin asserts rule expression-invalid on the 422 and the door pin asserts expressionFindings(result.errors). The message opens unknown field \COLUMN` on `TARGET`, the shared validator's own first-segment words (formula validate.ts 707 and 760), then names the reference read (record.book), the target, and where NOT to declare the field. One body for all three doors: runStackExpressionPassesis called byvalidateStackExpressions(the CLI'srunAuthoringRulesthrough authoring-rules.ts 688, foros validate, os build, os lint) and by the runtime gate with runtimeWriteType: 'object', under which judgeFieldFormulais not fenced. Thestored` pass (finding(lint): the runtime gate's object-write baseline drops the written object's stored self, so a master save is refused (422) for a stored detail's violation — the gate's "never charged to someone else's write" contract #22118, runtime-gate.ts 674–675) reads only findings located on ANOTHER entry, so a re-save of an object whose stored self already carries the fault is refused too: the WARN's "a re-save of the object refuses it with a located message" is true at this head for this fault, as the dev says, and not only for a first save.

  4. The isolation (resolveFormulaRelated, engine.ts 6287–6333; formulaHopScope, 2713–2755) — RIGHT, proven by the diff. wanted is built fresh per call from the plan (no plan set is mutated); for each reference the target's registry fields are read and undeclaredRelatedFields splits the union; the undeclared columns are deleted from the shared read set and recorded per reference; a reference left with nothing is dropped; the loader then reads only declared columns under authority: 'caller'. formulaHopScope first intersects THIS formula's own named columns with the reference's undeclared set and, on a hit, returns the undeclared-field refusal naming only those columns; otherwise it binds the row as before. Consequences: a valid sibling's read set equals its read set alone (pinned: one lib_book read with fields id,title; booted: ?fields=summary alone equals the full read); the bad formula reads null with its own report; reportFormulaFault is untouched, so the dedupe stays once per (object, field) per engine instance; the second-hop marker (formulaHopReads) and hopConflict entries are untouched. A formula reading only undeclared columns through a reference nobody else reads never reaches bindings (the undeclared check returns first), so the dropped reference costs nothing.

  5. The predicate seam — UNCHANGED, by the diff. resolvePredicateRelated (engine.ts 10207) has no hunk; collectPredicateRelationships has none; resolveOneHopRelated's only hunk replaces declaredHere/undeclared with the shared helper, and the two are extensionally identical (declared absent → []; else the names that are neither id nor in declared). The authority: 'predicate' branch keeps the whole-reference undeclared verdict, which is the out-of-scope finding in ③.9.

  6. The census row (federated-injected-column-readers.test.ts, engine.ts#resolveFormulaRelated :: referenceTargetOf() → caller-predicate) — RIGHT. exempt would be unverifiable (its ASKS claim is a same-site isFederatedObject call, and this site makes none); caller-predicate is the class the rule-validator's addReferenceTraversals and referenceTraversalConflicts rows carry for the same kind of use — an author-written expression's own relation path — and its why is true: the site reads the registry's field map and no row (the rows are read in resolveOneHopRelated, which keeps its own rows).

  7. The pins — RIGHT and sufficient for the two halves: refusal naming field and target; id and created_at admitted; an absent target not judged; the door with a cross-package target (LIT and CONTROL); the engine sibling served as alone with one read and only the bad formula reporting; the booted PUT 422 / 200 control / list and findOne values. No pin asserts a value in its title.

  8. What is red, and why it is this diff's — the Test Core (2/6) failure. packages/core's test:repo runs the six files of vitest.repo-tests.json, among them src/security/second-object-read-exposure.pin.test.ts: the [#22661] census that git greps every non-test .ts under packages/ for a CALL of referenceTargetOf and requires each caller file to have a row in its CALLERS ledger (expect(unclassified).toEqual([])). This diff adds the import and one code-line call in packages/lint/src/validate-expressions.ts (line 1989 at the head; zero calls at the merge base), and no row for that file exists in the ledger — object-graph.ts, validate-field-consumers.ts and validate-object-references.ts each have a not-a-served-read row, validate-expressions.ts has none. The pin reds on exactly this head and on every head since d74c110cb1. The dev's report says test:repo was not run locally and the 75 derived gates do not include it; the per-package lint and objectql suites cannot see a packages/core repo pin. The fix is one row ('packages/lint/src/validate-expressions.ts': { kind: 'not-a-served-read', reason: 'authoring-time static analysis of metadata; reads no record' }), which is a new commit and therefore a new head and a new record. A second census note while the file is open: the engine.ts row's also prose names referenceExists and resolvePredicateRelated ("the value never served") and nothing of the formula read, which since A formula field that reads through a reference (record.book.title) passes validateExpression, then reads null on every row: the read path projects only the row's own columns #24197 serves the related VALUE to the caller through the engine's own find without asking the target's API exposure decision. The row passes mechanically (the file is classified), so this is prose drift from A formula field that reads through a reference (record.book.title) passes validateExpression, then reads null on every row: the read path projects only the row's own columns #24197, not from this PR — escalated in ③.10.

  9. Public surface. No export moves in either package; FormulaRelated is a module-private type; undeclaredRelatedFields is a module-private function; referenceTargetOf is already published by @objectstack/spec. The card's two targets are met at this head: the save refusal names field and target; one formula's failed read no longer nulls its siblings.

② Semver level

  • @objectstack/lint minor with fix(lint)!:, **BREAKING**, the launch-window sentence, Clause-②: no (narrowing), the ADR-0087 marker, a FROM → TO and a "What to do" — RIGHT. An accept-set narrowing is breaking; the convention ships it minor. Its adr-0087: not-required (no-migration-prescription) reason is true: no key, shape or spelling moves; a stored formula with the fault stays stored and reads null as before; the only repair is the author's choice of field, which no migrate meta step can make.
  • @objectstack/objectql patch — RIGHT for the code: nothing accepted is refused; a read that served null serves the value; the bad formula still reads null; the only other observable change is the WARN's attribution. Its ADR-0087 reason is true. WRONG on the declaration line: this changeset carries Clause-②: no (narrowing), and check-adr-0087-registration.mjs (breakingDeclaration, signal 4) reads the narrowing arm as "this changeset declares a breaking change" — so the file reads as breaking at patch, which AGENTS.md's own sentence ("(narrowing) is BREAKING", shipped minor with a carrier) forbids, and the central release notes compiled from changesets would list objectql as breaking when it narrows nothing. No gate catches the pair: the level axis of check-changeset-no-major.mjs stands down on a no value, and the ADR-0087 gate is satisfied by the marker that is present. The dev's deviation 4 explains it (the dispatch asked for the line in "both changesets"; the honest reading of that ask is the PR's line on the BREAKING changeset, per AGENTS.md's "the changeset body, which also carries the PR's Clause-② line"). Correction, on the next head: the objectql changeset's line becomes Clause-②: no (no arm) — its marker then is surplus and harmless — and patch stands. The PR body's Clause-②: no (narrowing) is right for the PR, whose narrowing is the lint's.
  • Corpus: my census at the head finds 22 Field.formula builders in shipped, non-test sources — 7 in examples (app-crm 4, app-showcase 2, app-todo 1) and 15 platform / plugin display_titles — and no YAML or JSON formula field; zero of their expressions read record.X.Y. The dev's count of 28 differs; the material claim (0 traverse a reference, 0 new refusals in-tree) holds. No example, seed or fixture is refused.

③ Boundary flags

  1. Detached primary checkout and deleted local branch (deviation 1). Answered, no contract effect: the remote branch is the implementation identity and was pushed empty before any edit; the primary checkout's HEAD state is the worktree protocol's concern (AGENTS.md PD Migrate documentation site to Fumadocs with monorepo structure and shared content #11), not this record's. The adopting seat may want the primary checkout re-attached to main.

  2. Two pin files beyond the claim (deviation 2). runtime-gate.object-formula-writes.test.ts is the same package and the same lane as the declared validate-expressions.ts; it pins the declared change at the door and is covered by the declaration's substance, not its letter — a one-line amendment on the domain:spec seat posts is the tidy form, not a block. packages/qa/dogfood/test/formula-hop-undeclared-related-field.dogfood.test.ts is a new file in a lane the claim did not declare; the dev defers its declaration "to review". ESCALATED to the adopting seat: post the cross-lane declaration to domain:cli's seat post before the queue, as the lint one was posted to domain:spec. The Governed Surface Queue Guard is green, so it is not a governed path.

  3. Only the overlap re-run after the second main merge (deviation 3). Answered by the head's check-runs, which ran the whole battery on 313ee1040f: the overlap the dev chose was right for the merge, and the red that exists is not in the overlap — it is a repo-level census the dev never ran on any head. The lesson for the next head: packages/core test:repo is the one suite that sees a new caller of a spec arbiter from any package; run it locally when a diff adds an import of referenceTargetOf, referenceCarrierOf or the other names its SEAM_CALLS list carries.

  4. The Clause-② line in both changesets (deviation 4): the objectql one is the declaration defect in ②; correction owed on the next head.

  5. Build-time refusal of an extension-contributed target field (①.2, second bullet). Not blocking: the door — the card's seam — is unaffected; the class pre-exists for the first segment; the window needs a stack carrying both the target's owner and its extender. Worth one sentence in the lint changeset's "What to do" ("at os build, a target field an objectExtensions entry contributes is not visible to this pass"), or a card in the lint lane folding objectExtensions into buildFieldIndex for every existence verdict at once. Dedupe words: lint field index objectExtensions, build-time unknown field extension-contributed, formula hop extended target field.

  6. Open question 1 (the code family; A taken) — answered RIGHT: the dispatch's no-new-code rule, the expression-invalid rule, and the first- and second-segment verdicts read as one family by their opening words.

  7. Open question 2 (where the isolation lives; A taken) — answered RIGHT: the measured cause is the formula read's union, and B would have moved rule verdicts on the predicate seam, outside the card's fence. The shared helper is the one reading both seams make of "declared".

  8. Open question 3 (a target the snapshot does not carry; A taken) — answered RIGHT, and the door's snapshot is as the dev says: the live registry's merged objects plus stored rows, narrowed to system objects, overlay rows, the written package and its declared dependencies (runtime-gate.ts 416, protocol.ts 7373–7420).

  9. Open question 4 (the WARN text; A taken) — answered RIGHT, with the stronger ground in ①.3: the door's stored pass never lets the stored self's own findings cancel, so the re-save the WARN promises is refused, not only a first save.

  10. Out-of-scope finding 1 (the predicate seam's whole-reference undeclared verdict for validation rules). REAL at this head and independent of this PR, by the code: resolveOneHopRelated under authority: 'predicate' still answers the whole reference undeclared-field when any rule's column is undeclared (engine.ts 10346–10349), and the lint's check with traversalHydration judges a rule condition's hop shape, not its target columns. So one validation rule naming an undeclared related field saves 200 and then every rule through that reference has no verdict, refusing every write with the undeclared message. That is a public write door answering wrongly for the author's other rules (fail-closed, so no disclosure), with a reproducible shape; under PD chore: version packages #10 it meets the filing gate. The adopting seat files it (the dev's dedupe words stand), with the lint half (judge a rule condition's hop columns as this PR judges a formula's) and the engine half (per-rule isolation, the mirror of formulaHopScope) named as the two seams. Also filed with it, or as its own card: the second-object-read-exposure ledger's engine.ts row (①.8) now under-describes the formula read; the question it leaves open — whether a formula's hop into an object the API door does not serve should ask that object's exposure decision — is security(data, analytics): a lookup target's exposure declaration is not judged when the data door's $expand, or the dataset door's dimension-label pass, reads it — detail withheld pending maintainer #22661's own question and belongs to the owning seat, not to this PR.

  11. Out-of-scope finding 2 (formulas.mdx "Field must exist" row). Docs only, no misstatement (the row describes the first segment truthfully; the one-hop paragraph at line 104 is unchanged and true). The "Flag docs affected by code changes" check is green. Below the filing gate; a docs-only PR may add the second-segment example to the row when the lint lane next touches the page.

  12. Reviewer's own flags, none blocking beyond item 13:
    a. refuseUndeclaredHopField loops for (const obj of objects) and judges every object of the carrying name; the door's snapshot carries one (the written item replaces its stored self), and a stack with two objects of one name is refused elsewhere. No duplicate finding in practice.
    b. The lint trims the target name (?.trim()) but fieldIndex keys on the untrimmed obj.name; a target spelled with surrounding whitespace is not judged. Cosmetic.
    c. The loan fixture in the booted pin declares rhf_loan's bad_title in code, which the door never judged; os build of such a package would now refuse it. The fixture is test-only and is loaded through bootStack, not built — fine, and worth knowing when the dogfood shard list next changes.

  13. Verdict ground. The head is not green: Test Core (2/6) and Test Core concluded failure, and the cause is in this diff (①.8) — a new, unclassified caller of the reference-target arbiter. Tier S lands only on a PASS record for the current head with every check green; a record cannot pass a head whose own gate failed for a defect the diff introduced. The contract judgments above are otherwise RIGHT, so the next head needs: the one census row in packages/core/src/security/second-object-read-exposure.pin.test.ts; the objectql changeset's line corrected to Clause-②: no; the domain:cli declaration for the dogfood pin; and packages/core test:repo green on that head. That head takes a new record, which can be a delta on this one.

Implemented-by: claude/issue-24410-formula-related-field-refusal
Reviewed-by: session_01JtSQQ3EWsbNPzyYMug1tXy

VERDICT: FAIL


Generated by Claude Code

…the objectql changeset declares no narrowing

The [#22661] census requires a CALLERS row for every source that calls the
reference-target arbiter; validate-expressions.ts now does, as authoring-time
static analysis. The objectql changeset is a patch and narrows nothing, so its
Clause-② line carries no arm; the lint changeset names the objectExtensions
blind spot at build time.

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

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 362cd23e347656b16b605a7af60b5aeb88706e3e
Local-runs: none
Scope: .changeset/24410-lint-formula-hop-undeclared-field.md, .changeset/24410-objectql-formula-hop-sibling-isolation.md, packages/core/src/security/second-object-read-exposure.pin.test.ts, packages/lint/src/validate-expressions.ts, packages/objectql/src/engine.ts, packages/lint/src/validate-expressions.formula-one-hop.test.ts, packages/lint/src/runtime-gate.object-formula-writes.test.ts, packages/objectql/src/engine-formula-one-hop.test.ts, packages/objectql/src/federated-injected-column-readers.test.ts, packages/qa/dogfood/test/formula-hop-undeclared-related-field.dogfood.test.ts + packages/core/vitest.repo-tests.json and packages/core/package.json (test:repo), packages/core/src/security/row-serving-door-exposure.pin.test.ts (the sibling census, its canReadObject discriminator), packages/lint/src/object-graph.ts, packages/lint/src/validate-field-consumers.ts and packages/lint/src/validate-object-references.ts (the three sibling not-a-served-read rows' call sites), packages/lint/src/system-fields.ts (injectedColumnsFor), packages/lint/src/authoring-rules.ts (validateStackExpressions, the one body), packages/lint/src/runtime-gate.ts (narrowObjectsToPackageClosure, buildRuntimeWriteSnapshotSet), packages/metadata-protocol/src/protocol.ts (assertRuntimeAuthoringRules, its listCollection('object', 'objects')), packages/objectql/src/registry.ts (listItems, getAllObjects, resolveObject, foldExtenders), packages/cli/src/commands/compile.ts, validate.ts and lint.ts (runAuthoringRules), packages/lint/src/validate-mapping-target-fields.ts and validate-security-posture.ts (the two lint passes that DO fold objectExtensions, for contrast), scripts/pm/clause2-line.mjs (readClause2Line), scripts/check-adr-0087-registration.mjs (breakingDeclaration, G1), scripts/check-changeset-no-major.mjs (the level axis), scripts/pm/record-recognisers.mjs (--paths, the face)

This is a DELTA on record 6260609012 (FAIL on head 313ee1040f, on one ground: the [#22661] census had no row for the lint's new referenceTargetOf call). Inputs: that record in full (the baseline), the dev's fix-round report 6261616858 on #24410 (read as claims), the seat's CI note 6260464854, the two domain:cli declarations 6260339978 (#6024) and 6260346158 (#22648), PR #24466 (body, the 10-file list), the net diff of the new head against its base 31ce45c071 and of the old head against its base fa26308bb0 (fetched to refs/remotes/pr/24466b, compared per file with git diff; nothing checked out, built or run), and the new head's check-runs.

The delta, measured. Between the heads: one merge of origin/main (94de92be1f, merging 31ce45c071, 18.0.0-next.4) and one fix commit (362cd23e34). Per-file comparison of the two net patches (index lines stripped): seven paths are byte-identical in hunk and context — packages/lint/src/validate-expressions.ts, packages/objectql/src/engine.ts, the two lint pins, the two objectql pins and the dogfood pin. Three moved, exactly as the seat measured: packages/core/src/security/second-object-read-exposure.pin.test.ts (new in the PR, +5: one CALLERS row), .changeset/24410-objectql-formula-hop-sibling-isolation.md (one line, Clause-②: no (narrowing) to Clause-②: no), .changeset/24410-lint-formula-hop-undeclared-field.md (one sentence appended to "What to do"). Net +398/−9 over 10 files; the PR's file list agrees.

Check-runs on the head. First read 07:16:27Z: 40 runs, 28 success, 6 skipped by design, 6 in progress (the six Test Core shards). Final read 07:23:46Z: 32 success, 6 skipped, 2 still in progress: Test Core (2/6) and Test Core (4/6); Test Core (1/6), (3/6), (5/6) and (6/6) concluded success; the aggregate Test Core has not yet reported. Green at the final read, among them: Check Changeset, Governed Surface Queue Guard, Build Core, every Lint job (ESLint, repo & tooling gates, CI/release/engine gates, docs & code guards, PM dispatch-gates, the three scoped jobs), Type Check source/consumer/debt/workspace, Spec property liveness, Dogfood Regression Gate, Flag docs affected by code changes, the three claim and card guards. No run has concluded failure. Test Core (2/6) is the shard the seat's CI note names as the carrier of packages/core test:repo; its conclusion is not in hand at this read and was not waited for.

① Derived judgments

  1. Carried by reference. The earlier record's ①.1 (the refusal as implemented), ①.2 (what can and cannot be refused, per case), ①.3 (code and message, the one body for three doors), ①.4 (the isolation), ①.5 (the predicate seam unchanged), ①.6 (the objectql census row), ①.7 (the pins) and ①.9 (public surface) stand unchanged for the seven paths whose net patch did not move; I re-read nothing there that the delta did not touch, and the per-file comparison is the ground. ①.8 (what was red and why) is the cause this head removes; judged below.

  2. The census row, as a classification — RIGHT. The row is 'packages/lint/src/validate-expressions.ts': { kind: 'not-a-served-read', reason: 'authoring-time static analysis of metadata; reads no record' } (pin file 204–208). The call it classifies is at validate-expressions.ts 1989, inside refuseUndeclaredHopField (1979–2003): for each object of the carrying name it walks fieldEntries(obj), asks referenceTargetOf(def) for the hop's target, and judges each traversed column against fieldIndex — buildFieldIndex(objects) (170–200), the authored field names plus injectedColumnsFor(obj). Every input is the stack's metadata and the formula's CEL source (analyzeRelationshipTraversals); the file imports nothing from @objectstack/objectql, @objectstack/core or @objectstack/metadata-protocol, calls no engine, driver or registry, and serves nothing to any caller — the pass runs under runAuthoringRules for os build / os validate / os lint (compile.ts 553, validate.ts 530, lint.ts 681) and under the runtime gate at the save door (authoring-rules.ts 688, runtimeWriteType), where it reads snapshots, not rows. "Authoring-time static analysis of metadata; reads no record" is the true description.

  3. Consistency with the three sibling packages/lint rows — RIGHT. object-graph.ts (323, reference: referenceTargetOf(def)), validate-field-consumers.ts (995) and validate-object-references.ts (335) each call the arbiter on a field definition inside a metadata walk, none reads a record, and each carries the identical not-a-served-read reason. The four files are one class, and the identical reason string says so rather than inventing a fourth wording for the same fact. The // [#24410] lead comment follows the pin's own convention ([#22661], (#22739), (#22737) in the header).

  4. Every assertion of the pin, against the row — satisfied, by reading. The pin has four it blocks. (i) "every caller is classified, and every classified file still calls it": the scan is git grep --files-with-matches --untracked --fixed-strings referenceTargetOf -- packages, filtered to non-test .ts and then to files with a CODE line matching \breferenceTargetOf\s*\(; validate-expressions.ts matches (the import at 118 is reached by the prefilter; line 1989 is a code line and a call), and the new row makes unclassified empty; stale stays empty because the file does call it. (ii) "each decided read asks the decision…": iterates kind: 'decided' rows only; the new row is not one. (iii) "each executed read names a decided door": kind: 'executes' only. (iv) "the scan cannot pass vacuously": the prefilter must reach every classified file and the matcher must fire on each — both true for this file by (i). There is no further not-a-served-read invariant in the pin: the Classification union gives that kind one field, reason: string, and no test reads it. The dev's report claims the reproduced red (expected [ Array(1) ] to deeply equal [], naming this file) and 82/82 on this head; the claim agrees with the reading, and the gate that carries it (Test Core (2/6)) is the one still running.

  5. The right home, and the only one — RIGHT. Two other pins were candidates. row-serving-door-exposure.pin.test.ts keys its census on a CALL of .canReadObject( (its ADMISSION), and validate-expressions.ts calls neither that nor apiExposureDenialReason / canServeApiOperation: the lint is outside that pin's radius by construction, not by omission. federated-injected-column-readers.test.ts (objectql) scans "the whole package source" of packages/objectql and keys its SEAM_CALLS rows by file#function :: seam(); a packages/lint file is outside it too, and that census already carries the engine's own resolveFormulaRelated :: referenceTargetOf() row from the previous head. The [#22661] census is the one pin whose discriminator — a call of the arbiter in any non-test .ts under packages/ — reaches the lint file, and it is run by packages/core test:repo (vitest.repo-tests.json, the repo project), which is why no per-package suite saw it. Nothing else should name the call.

  6. The earlier ①.8, closed and what remains of it. The cause named there is exactly what the row removes; the row is the one the earlier record prescribed, verbatim. The second note in ①.8 — the engine.ts row's also prose names referenceExists and resolvePredicateRelated and nothing of the formula read — is unchanged at this head (pin file 136–144) and remains A formula field that reads through a reference (record.book.title) passes validateExpression, then reads null on every row: the read path projects only the row's own columns #24197's drift, escalated in the earlier ③.10; it passes mechanically and is not this PR's to fix.

② Semver level

  • @objectstack/lint minor with fix(lint)!:, **BREAKING**, the launch-window sentence, Clause-②: no (narrowing), the adr-0087: not-required (no-migration-prescription) marker, a FROM/TO and a "What to do" — carried RIGHT from the earlier ②. The new sentence ("At os build and os validate this pass does not see a target field that an objectExtensions entry contributes, while the save door judges the merged object") is TRUE on both halves, by the code. First half: validate-expressions.ts contains no objectExtensions handling at all (zero occurrences in the file), buildFieldIndex reads obj.fields of the stack's objects only, and the CLI assembles no fold before runAuthoringRules — packages/cli/src mentions objectExtensions only to count them (utils/format.ts) and to walk picklist references (utils/picklist-references.ts); the two lint passes that do fold extensions (validate-mapping-target-fields.ts 114–116, validate-security-posture.ts 555–556) do it for themselves and feed nothing into this pass's index; validate-field-consumers.ts 167 states the same stance for its own verdicts. Second half: the door's context.objects is built in assertRuntimeAuthoringRules by listCollection('object', 'objects') over this.engine.registry.listItems('object') (protocol.ts 7373 ff.); listItems('object') returns getAllObjects() (registry.ts 5390–5394), which pushes this.resolveObject(fqn) per name (4234), and resolveObject is foldExtenders(contributors, base) over the owner or overlay (3277–3313). buildRuntimeWriteSnapshotSet then narrows that list to the package closure (runtime-gate.ts 706–709) without unfolding anything. So the door's target is the merged object and the build's is the authored one. The sentence states the ③.5 window of the earlier record in the consumer-facing place that record asked for, and it is the whole of what moved in this changeset.
  • @objectstack/objectql patch — RIGHT, and now rightly declared. The line is Clause-②: no with no arm. readClause2Line (clause2-line.mjs 418 ff.) reads it as { kind: 'declared', value: 'no', arm: null } — an absent parenthetical is arm: null, not malformed (285). breakingDeclaration (check-adr-0087-registration.mjs 673–685) then collects no signal: the frontmatter is patch, not major (signal 1); the body carries no **BREAKING or BREAKING CHANGE (2); the summary is fix(objectql): with no bang (3); the arm is not narrowing (4). Zero signals is breaking: false, which is the gate's G1 ("a non-breaking changeset is not this gate's business"), so the ADR-0087 marker that remains in the body is surplus and inert — the gate never reads a marker on a changeset it does not judge. check-changeset-no-major.mjs introduces no major offence here, and its level axis ([finding] No gate answers whether a changeset's LEVEL fits the surface — Check Changeset is green on patch and on minor for the same diff #16055, the block at 872 ff.) fires on a Clause-②: yes declaration only, so a no stands it down whatever the arm; the earlier record's reading holds. The file therefore no longer reads as a breaking declaration at patch, which was the ② defect. The PR body's Clause-②: no (narrowing) is right for the PR: the narrowing is the lint's, and record-recognisers.mjs --paths on the 10 files confirms the face is read off the lint changeset's arm (the paths themselves decide none; no published schema, no governed path). Both gates are green on the head.
  • Corpus — carried from the earlier ② (22 in-tree formula builders, none reading record.X.Y; no example, seed or fixture refused). The delta touches no formula.

③ Boundary flags

  1. The earlier ③.13, item by item.

  2. Dev deviation 1 (one push instead of a WIP push before the gate sweep). Process, no contract effect. The rule it names — AGENTS.md: "Push a WIP commit before every step that takes minutes … because the working tree is otherwise the only copy of your work" — guards against losing work; the fix commit sat local about 25 minutes and was not lost. The "budget of one push" it cites is not a rule I find in AGENTS.md or .claude/**; it is the round's own constraint, and reconciling the two is the dispatching seat's, not this record's. Nothing in the diff turns on it.

  3. Dev deviation 2 (worktree recreated from the origin branch). No contract effect. The implementation identity is the remote branch; a worktree re-made from it carries the same commits, and the net patches of the seven unmoved paths are byte-identical to the previous head's, which is the measurement that would have caught a drift.

  4. Dev deviation 3 (PR body not updated). Not blocking. The body's Clause-②: no (narrowing) line is right for the PR and is the only thing in the body a gate reads for the declaration; its head citation (313ee1040), its "75 commands" and its 9-row diff table (missing the core pin) are description that has gone stale by one commit. The recogniser reads this record's Head-sha and the queue guard reads the PR's file list, neither the body's prose. The adopting seat may append the new head, the core census row and the dev's 77-command claim to the body; it owes nothing before the queue.

  5. Carried from the earlier ③. Items ③.1–③.4 are answered as above or superseded by the delta. ③.5 (the build-time window for an extension-contributed target field) is now stated in the lint changeset's "What to do" in the form that record asked for; the lint-lane card folding objectExtensions into buildFieldIndex for every existence verdict remains open and is the dev's second out-of-scope note. ③.6–③.9 (the four open questions) stand. ③.10 (the predicate seam's whole-reference undeclared verdict, plus the engine.ts census row's prose drift) remains the adopting seat's filing, as the dev's first out-of-scope note restates. ③.11 (the formulas.mdx row) and ③.12 (the reviewer's three cosmetic flags) stand unchanged.

  6. Reviewer's own flags on the delta, none blocking.
    a. The surplus ADR-0087 marker on the now non-breaking objectql changeset is inert under G1; removing it would be tidier and is not owed.
    b. The aggregate Test Core run has not appeared on this head at my read (the old head had 41 runs; this head shows 40). It follows the shards.
    c. The merge of 31ce45c071 brings a spec move; the dev reports check:generated clean and the Spec property liveness and Check Changeset runs are green on the head, so nothing of it lands in this PR's surface.

  7. Verdict ground. The one ground the earlier record failed on is removed by the exact row it prescribed, and the row is the true classification, consistent with its siblings, in the only pin whose radius reaches the call. The two changeset edits are each true and move the objectql declaration out of the breaking class without touching the PR's own (narrowing) face. Every contract judgment of the earlier record is carried for the seven unmoved paths and holds for the three moved ones. No check has concluded failure on this head; two shards are still running at my read and were not waited for, and the landing's "every check green" half is read by the queue guard when they conclude, not by this record.

Implemented-by: claude/issue-24410-formula-related-field-refusal
Reviewed-by: session_01JtSQQ3EWsbNPzyYMug1tXy

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对 · domain:engine seat 1 · session_01JtSQQ3EWsbNPzyYMug1tXy · 2026-10-19T07:35Z · ACCEPT

Head: 362cd23e34 (the refusal, the isolation, the pins, a merge of main, then fix round 1: the core census row and two changeset edits). Card #24410 (p1, target:v18, bug), claim 6258777300, dispatch 6258848224. Dev reports: 6260190420, fix round 6261616858.

Item Reading
Shape Draft PR → main. The body opens Fixes #24410. 10 files, +398 / −9. Not governed.
Scope packages/lint's judgeFieldFormula refuses a one-hop read of a field the target does not declare, under the existing expression-invalid rule. objectql's resolveFormulaRelated leaves undeclared columns out of the shared read, and formulaHopScope judges each formula on its own. The predicate seam is untouched. Pins in lint, objectql and a booted dogfood test; one census row in packages/core's [#22661] pin.
Seat's own read Between the two heads, only three paths' net patches moved: the census row, the objectql changeset's line and the lint changeset's sentence. Every other file's patch is byte-identical (measured per file).
Measured Booted, on main: the save answered 200 and a valid sibling read null. After: the save answers 422 INVALID_METADATA naming the field and the target, and the sibling reads its value (equal to ?fields= alone).
Ablation Both mechanisms ablated, each red, each restore checked.
Review CONTRACT_REVIEW_TIER, narrowing face: record 6260609012 FAIL on 313ee1040f (the census row), then delta record 6262025060 PASS on this head.
Declaration Clause-②: no (narrowing) on the claim, the PR body and the lint changeset. The objectql patch changeset says Clause-②: no.
Cross-lane packages/lint declared to domain:spec (6258789472, 6258795887); the dogfood pin to domain:cli (6260339978, 6260346158). No objection on the card.
Gates Dev, on 362cd23e34: 77 derived, 77 run, 77 exit 0; packages/core test:repo 82/82.
CI At this read on 362cd23e34: every check completed, none failed.

Disposition:

Landing: ordinary queue (pr_ready + automerge_enable through the relay). Fixes #24410 then closes the card, and #24468 is claimed behind it.


Generated by Claude Code

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 size/m tests tooling

Projects

None yet

2 participants