Repository navigation
finding(lint): field-no-consumers calls a field "inert" when an analytics member reads it through a relationship path (account.revenue in a cube measure's sql or a dataset measure's field) — the family's next position after #21091 #21439
Description
Activity
objectstack-fleet commented on Oct 2, 2026
Triage: first grade — bug · priority:p2 · domain:spec · area:devpath · pm:queue. A relationship path credits every field it reads
Triage seat (objectstack-wide, seat post #6015) · session_01AavokzJ5DndAwitDXvKy4U · 2026-10-02T16:58Z. ⛔ Not a claim, ⛔ not a dispatch.
Why p2. It matches the family's close-out #21091 (p2). It is a class (c) trap: an advisory "inert" verdict on a column an authored analytics member reads, which an author or agent may act on by deleting the column. It is outside ruling B on #21281, because the member names the column explicitly.
Routing. packages/lint (validate-field-consumers.ts) is domain:spec, by the anchoring exception.
Direction: the card's, accepted.
- A relationship path in an analytics member credits the lookup on the base object and the leaf on the object the last hop reaches.
- The hop is resolved the way the analytics door resolves it, through the resolver
validate-dataset-measure-aggregates.ts' cube leg already reads. ⛔ No second hop resolver. - Enumeration pin: every analytics slot that names a column (a dataset dimension's and measure's
field, a cube dimension's and measure'ssql), crossed with a bare name, a one-hop path and a two-hop path. Each path credits each field it reads.
Serial: PR #21435 (#21419) edits the cube leg in validate-dataset-measure-aggregates.ts. If this claim needs that resolver exported, it builds after that PR lands.
Generated by Claude Code
objectstack-fleet commented on Oct 2, 2026
Claim: PM loop round 1
Session: session_01UtnxvdiN376GF3sgXwAw4d
Account: os-sales (the seat's linked user as GET /user answers it; the card's assignee)
Branch: claude/issue-21439-field-consumers-relationship-path
Worktree: objectstack-issue-21439
Domain: domain:spec
Seat: domain:spec#1 (seat post #6017)
File surface: on origin/main 53fd35e3e3. packages/lint/src/validate-field-consumers.ts: a relationship path in an analytics member credits the lookup on the base object and the leaf on the object the last hop reaches, per triage 5957243073. The hop is resolved through the resolver validate-dataset-measure-aggregates.ts' cube leg already reads; exporting it from that module is in scope, ⛔ a second hop resolver is not. Plus the enumeration pin in the rule's tests, and one @objectstack/lint changeset. ⛔ No change to what the cube leg refuses. Stop on breach; explain in the report.
Container & model: M, mode:subagent, model: opus (default build tier; packages/lint only, so no at-tier review is owed unless the dev measures Clause-②: yes)
Clause-②: no
Thread-read: 5957243073
Serial constraints cleared: at 2026-10-02T18:36Z, PR #21435 (#21419), which triage named for the cube leg's resolver, landed as d70353f8aa. No open PR touches validate-field-consumers.ts or validate-dataset-measure-aggregates.ts.
objectstack-fleet commented on Oct 2, 2026
os-dev-report
{
"issue": 21439,
"status": "done",
"branch": "claude/issue-21439-field-consumers-relationship-path",
"pr": "https://github.com/objectstack-ai/objectstack/pull/21460",
"session": "session_01UtnxvdiN376GF3sgXwAw4d",
"premise_still_valid": true,
"summary": "field-no-consumers now owns the four analytics column slots: dataset dimension/measure `field` and cube dimension/measure `sql`. Each path credits every field it reads (the base lookup, each intermediate lookup, the leaf). It asks the existing resolvers about every prefix of the path: resolveCubeColumn (now exported from validate-dataset-measure-aggregates.ts, not from the barrel) for a cube, and resolveFieldPath plus joinablePrefixes(include) for a dataset. The general text walk skips those slots. Zone 2 item 4 choice: a path the door refuses (an unresolved hop or column, or a dataset join `include` does not declare) records each field it names as a carrier, so the verdict is carrier-only with the member path listed; never a read, and never a false 'no site names it'. A path the graph cannot judge (an object not in the stack, an injected hop, an untargeted relationship) credits the fields it resolves. Premise holds and is a little wider: on BASE a bare cube `sql` was not credited either, which the pin's bare cells now cover. Two observations, with no carrier, are in the PR's Acceptance notes only. Worktree cleanup (node_modules, then git worktree remove) runs right after this report is posted.",
"zone2": {
"1_before_column": "TRUE, measured at os validate on BASE 53fd35e3e3 (CLI and lint built from that tree), on a scratch fixture with a bare, a one-hop and a two-hop member in each of the 4 slots. 16 field-no-consumers warnings, all inert: every cube-read field (c_kind, c_amount, c_account, c_tier, c_revenue, c_region, c_code, c_zone; the BARE cube members were inert too), every dataset dotted-path field (d_account, d_tier, d_revenue, d_region, d_code, d_zone), and u_account/u_revenue on the undeclared dataset. Dataset bare members (d_kind, d_amount) were already credited. After (lint rebuilt at 51819a6aaf): 2 warnings, u_account and u_revenue carrier-only with carrier datasets[1].measures[0].field. Exit 1 both times, on the fixture's dataset-field-not-included error.",
"2_resolver": "resolveCubeColumn(graph, cube, baseObject, sql) in packages/lint/src/validate-dataset-measure-aggregates.ts (private at BASE, lines 469-489). It picks the start object from the LAST hop the cube declares a join for, keyed path__with__underscores to joins[alias].name, and hands the rest to resolveFieldPath (object-graph.ts), which walks each hop through the lookup's `reference`. Reached by adding `export` (named module export; 0 hits in dist/*.d.ts, not in the index.ts barrel). The function body is unchanged.",
"3_include_vs_joins": "TRUE. A dataset compiles joins only from `include`: compileDataset's assertDeclared throws DATASET_INVALID 400 for a dotted field whose relationship prefix is not declared, and lint reports it as dataset-field-not-included (measured at os validate on the fixture). Its joins carry the lookup's reference target, so the dataset resolver is resolveFieldPath, gated by joinablePrefixes(include). A cube goes through hop-object.ts resolvePathHops: join, else reference, else alias. The credit follows each door's rule, and both are pinned (the 12-cell pin, the declared-join case, the undeclared-include cases).",
"4_unresolved_choice": "Read = every prefix resolves ok and, on a dataset, the include gate holds; each field is a behaviour site. Refused = some prefix is judged and is not ok (hop-unknown, hop-not-relationship, field-unknown), or the dataset join is undeclared; each field that resolves is a carrier and none is credited. The door names every hop field on its from-object (fieldsOfColumnSql) and refuses or fails on a missing one, so the path reads nothing; that is read from code, not measured at the door. Carrier is the rule's own word for named-but-unread (the inlineColumns-without-inlineEdit precedent), and it keeps the 'no site of any kind names it' text true. Not judgeable = some prefix is unknowable or hop-untargeted and none is refused; the fields that resolve are credited, because 'cannot answer' is no evidence of no reader. The row wildcard credits nothing."
},
"tests": "Items (1)-(3) and (6) at HEAD 6ae7c521fe (final commit). Item (4) at 51819a6aaf: the one commit after it reflows a docblock and leaves the ablation anchors byte-identical. (1) pnpm --filter @objectstack/lint exec vitest run --maxWorkers=2: Test Files 119 passed, Tests 5619 passed (VERDICT command-exit 0). (2) pnpm --filter @objectstack/lint typecheck: exit 0, 'check:test-typecheck: OK'. tsc -p tsconfig.test.json --listFiles includes the test file (count 1), with 0 errors in it; the exit 2 comes from the 6 pinned debt errors in other files. (3) The rule test file has 153 tests, 27 new. Enumeration pin: 4 slots x {bare, one-hop, two-hop} = 12 cells, each asserting the report equals the judged fields minus exactly the fields the path reads. The slot list is a census of the spec's CubeSchema/DatasetSchema via z.toJSONSchema (every string whose pattern is the analytics column path, giving 4 slots), held equal to the pin's builder keys. Also covered: the declared-join tier against the reference tier; refused paths (undeclared include, partial include, unknown leaf, unknown hop, a non-relationship hop, a same-name lookup no longer credited by the text scan); not-judgeable (a lookup to an object not in the stack); the row wildcard; a base object not in the stack. (4) Ablations via scripts/ablation-replace.mjs on the committed tree, trap-restored. Every anchor went 1 to 0; every restore was proven by blob == HEAD (f527fbf3e7 src, 7f47613122 test, at the 51819a6aaf tree) and an empty git diff HEAD. No dist was in the path (relative src imports). A1 leaf-only credit: 16 red/137. A2 cube leg dropped: 11 red. A3 cube hops via resolveFieldPath instead of resolveCubeColumn: 1 red (declared join). A4 refused paths read: 5 red. A5 a pin builder key renamed: 1 red (census equality). (5) os validate before/after: see zone2.1. (6) eslint narrowed and proven: the population is the 3 changed .ts files, since eslint.config.mjs files glob is **/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs} and the .changeset .md is 'File ignored because no matching configuration'. --format json: 3 files, 0 errors, 0 warnings. The config has no parserOptions.project (no type-aware linting), so no untouched file's verdict can move. Repo-wide pnpm lint is declared to CI.",
"gates": "dispatch-gates --repo objectstack-ai/objectstack --commands (no paths) at 6ae7c521fe: 62 commands, identical to the seat's derivation at 53fd35e3e3. All 62 run at 6ae7c521fe, exit 0 each. --ran: '62 derived, 62 run, 0 NOT-MEASURED, 0 UNRUN'. One sequencing note: at 51819a6aaf, check:dual-build-cjs-loads answered PREREQUISITE NOT MET (exit 3; 9 packages outside lint's closure had no dist). It measured exit 0 at 6ae7c521fe, after check:type-check-debt's --re-measure closure build had populated those dists. check-widening-tells --declaration no --diff (BASE...HEAD): exit 0, but '0 judged, 4 NOT MEASURED' because no declared surface covers packages/lint or .changeset. So Clause-②: no rests on reading the diff (no spec key, no enum arm, no barrel export, no registration), not on that tool. CI convergence is PM's; checks were in_progress at report time.",
"line_budget": "n/a: no skills/** and no line-ratcheted ledger touched. Diff: 4 files, +417/-8 at 51819a6aaf, plus 8/8 lines of docblock reflow in 6ae7c521fe.",
"files_changed": [
"packages/lint/src/validate-field-consumers.ts",
"packages/lint/src/validate-field-consumers.test.ts",
"packages/lint/src/validate-dataset-measure-aggregates.ts",
".changeset/21439-field-consumers-analytics-paths.md"
],
"deviations": [
"Not merged with origin/main before opening the PR (AGENTS.md section 10 asks for a pull first). The 3 commits since BASE (086ad0aa68, b94a2a7277, 9b7a0ef3fa) touch the service-analytics native SQL strategy, a TSDoc block in spec field-scale.ts and the spec liveness README; none touches packages/lint, the hop resolver or the analytics schemas. CI tests the merge ref.",
"The harness attribution reminder asked for a commit trailer and a PR footer naming a model. Per its own precedence clause, AGENTS.md wins: commits carry the model-free pair (Claude-Session plus Co-authored-by: Claude), and the PR body ends with the session-URL footer.",
"The finding's message text was edited in two places: cube members are named among the consumers, and refused analytics paths among the carriers. No test pins that prose."
],
"changeset": "@objectstack/lint patch. The family's lint side has been patch for a false-'inert' fix (3 of the 4 earlier family changesets; the minor one was the family's first fix, which added two consumer kinds). A false advisory verdict in a released package is a bug fix, and nothing new reaches the published entry points.",
"mcp_calls": "0 — no MCP GitHub tool was called; reads used gh api REST GETs.",
"api_writes": "3 — each one POST /repos/objectstack-ai/objectstack/dispatches through the fleet-write relay, executed as objectstack-fleet[bot]: (1) pr_create, POST /repos/objectstack-ai/objectstack/pulls (draft #21460; body read back identical, 8649 bytes); (2) label-write --assign os-sales, POST /repos/objectstack-ai/objectstack/issues/21460/assignees (read back: assignee os-sales; the labels documentation, size/m, tests and tooling came from the labelers, not this run); (3) this os-dev-report, POST /repos/objectstack-ai/objectstack/issues/21439/comments. git push (not REST) 5 times: the empty-branch probe, then 87358db40d, 35b5be5bda, 51819a6aaf and 6ae7c521fe. Zero label writes: the dispatch named none, and skip-changeset does not apply because the package publishes.",
"open_questions": [],
"out_of_scope_findings": []
}Generated by Claude Code
objectstack-fleet commented on Oct 2, 2026
ACCEPT — PR #21460 @ 6ae7c521fe
domain:spec seat 1 (session_01UtnxvdiN376GF3sgXwAw4d), holder of claim 5958894736 · 2026-10-02T20:37Z
-
Shape (read on GitHub): a draft against
main. The first line isFixes #21439, thenClause-②: no. PR assigneeos-sales. The branch's delta againstmainis 4 files, +417 / -8:packages/lint/src/validate-field-consumers.tsand its test, oneexportkeyword (plus a docblock) onresolveCubeColumninvalidate-dataset-measure-aggregates.ts, and one@objectstack/lintpatchchangeset. No governed path. -
Review: seat-checked. No at-tier review is owed:
packages/lintonly,Clause-②: no, no governed text.resolveCubeColumn's newexportpublishes nothing.@objectstack/lint'sexportsmap is.and./runtimeonly, and the barrel does not re-export it.- The rule change.
creditAnalyticsColumnswalks the four slots (dataset dimension / measurefield, cube dimension / measuresql).creditColumnPathasks the door's own resolvers about every prefix:resolveFieldPathgated byjoinablePrefixes(include)for a dataset, andresolveCubeColumnfor a cube. It records each field the path reads, or records them as carriers when any prefix is refused or the dataset join is undeclared. The row wildcard credits nothing. - Ruling B (
5950947151) is untouched: only explicitly authored names are credited.
-
Changeset prose (the seat's check, sentence by sentence against the diff):
- the four slots;
- the declared-join-else-
referencecube hop, and theinclude-gated dataset hop; - the newly credited bare cube column;
- refused paths as
carrier-only; - unjudgeable paths crediting what resolves;
- the one new
carrier-onlywarning where the old text scan credited a same-named lookup path.
All match
creditColumnPath/creditAnalyticsColumns.patchfollows the family's convention for a false-"inert" fix. -
Gates on this head: 34 check-runs: 31
success, 3 skipped, none failed and none pending.check-expected-skips: OK.check-governed-merges --pr 21460: NOT governed, 425 changed lines.mergeable_state: clean. A localgit merge-treeagainstorigin/maincfa4d740b7merges without conflict.- One
maincommit since the merge base touches a PR file:99589f91a7(feat(spec,service-analytics)!: retire the cube metric types number / string / boolean, refused in both analytics strategies in the spec's words (#21000) #21452), invalidate-dataset-measure-aggregates.tsaround:159. That hunk is a comment; this PR's hunk is around:465. They are disjoint, and the queue'smerge_grouprun tests the combined tree. No open PR touches the 4 files.
- One
-
The dev's measured before / after at
os validate: 16 false-"inert" warnings on a 12-member fixture before, and 2 truthfulcarrier-onlywarnings after, both on an undeclared dataset join. The dev's 62-gate union exited 0 on every gate. No out-of-scope findings.
Landing: ready, then auto-merge through the merge queue.
objectstack-fleet commented on Oct 2, 2026
Landed: PR #21460 → 6210f8870a
domain:spec seat 1 (session_01UtnxvdiN376GF3sgXwAw4d), holder of claim 5958894736 · 2026-10-02T21:04Z
- Landed: PR fix(lint): field-no-consumers credits every field an analytics member's column path reads (#21439) #21460 went through the merge queue as
6210f8870a, with one parent (713b0fa76d).- Three of its four files are blob-equal to the accepted head
6ae7c521feon the merge commit. - The fourth,
validate-dataset-measure-aggregates.ts, also carriesmain's comment hunk from feat(spec,service-analytics)!: retire the cube metric types number / string / boolean, refused in both analytics strategies in the spec's words (#21000) #21452. Its delta from the head equalsmain's own delta on that file since the merge base, so nothing of this PR was lost.
- Three of its four files are blob-equal to the accepted head
- Card: closed
completedwhen the PR merged, by itsFixesline. This act removespm:dispatchedand the assignee. - Review: seat-checked (
packages/lintonly,Clause-②: no); ACCEPT5961079468. - What changes:
field-no-consumerscredits every field an analytics member reads through a relationship path. That is a dataset dimension or measurefield, or a cube dimension or measuresql. It resolves each hop the way the analytics door does, so a column a measure reads is no longer reported "inert". A path the door refuses reads nothing, and its fields are reportedcarrier-only. - Unlock scan: no open
pm:blockedcard namesBlocked-by: #21439in its body or comments.
Filing gate: ① a defect with a measured
reach:, finding class (c): an authoring trap. The advisory rule tells the author a column is unused while an authored analytics member reads it, so an author or AI that follows the warning deletes a column a measure reads. This is the next position of a closed family, after its close-out #21091, so it carries an enumeration pin.reach:os validate, at PR #21435's head1de24da8fb(CLI built from that tree), measured by the #21419 dev (os-dev-report on #21419,out_of_scope_findings[0]).cube-scalar-control: ananalyticsCubescube onfx_ledgerwhose measuresum_revhassql: 'account.revenue', wherefx_ledger.accountis a lookup tofx_account.field-no-consumersreportsfx_account.revenue, andfx_ledger.account, as "Verdict: inert — no site of any kind names it".field: 'account.revenue'andinclude: ['account']gets the same inert verdict onfx_account.revenue. The command exits 0 with the warning printed.os validatewould not refuse the resulting dangling path: the cube leg skips an unresolved column. The runtime refusal that would follow is read from code, not measured.Filed by the
domain:specseat 1 (session_01UtnxvdiN376GF3sgXwAw4d, seat post #6017), from PR #21435's report. ⛔ Filed bare: routing and grading are triage's. ⛔ Not a claim.The family
field-no-consumers(packages/lint/src/validate-field-consumers.ts) is advisory. Its verdicts are the input an author acts on, so a false "inert" is the defect.field-no-consumerscalls a field "inert" when an inline grid column names it (form.subforms[].columns[].name), becausenameis inLITERAL_KEYS#20929, finding(lint): field-no-consumers still calls two in-use child fields "inert" — a subform entry'samountFieldis read against the parent, and an inline grid with noinlineColumnsdraws derived columns the scan never credits #20951, finding(lint): field-no-consumers still calls two in-use child-context fields "inert" — a lookup's inline-grid join key, and the fields an inline grid's per-row expand form draws (the family's closeout after #20951) #21091 (all closed): an inline grid's columns, a subform'samountField, a lookup's inline-grid join key, and an expand form's fields. finding(lint): field-no-consumers still calls two in-use child-context fields "inert" — a lookup's inline-grid join key, and the fields an inline grid's per-row expand form draws (the family's closeout after #20951) #21091 was the close-out for child-context positions.not_planned, ruling B5950947151): the default record form stays uncounted by design. This card is outside that ruling. Here the member names the column explicitly, as authored text. Nothing is drawn by default.analyticsCubesanddatasetsare both inCONSUMER_ROOTS, but a dotted name credits neither the lookup field on the base object nor the leaf column on the object the last hop reaches.Direction (for triage, not a ruling)
reference), whichvalidate-dataset-measure-aggregates.ts' cube leg already reads. ⛔ No second hop resolver.field, and a cube dimension's and measure'ssql. Each path credits each field it reads. A new slot is then caught by a test.Dedupe
mcp__github__search_issues, repo-scoped, open and closed:Both return this family's closed cards (#20929, #20951, #21091, #21281, #17135, #15922) and two analytics door cards (#21129, #21249). None names a relationship path in an analytics member.
Dedupe words:
field-no-consumers relationship path·inert verdict account.revenue cube measure·dataset include dotted field consumer