fix(lint): a field-typed rule reads the registry's own type for an injected column, so created_at / updated_at stop escaping it (#16340) - #17430
Conversation
…ield-typed rule can judge created_at / updated_at (#16340) The object graph recorded registry-injected system columns by NAME only, so a path resolving to one came back `{ kind: 'ok', injected: true }` with no `meta` and every rule asking a second question about the leaf had to stay silent. Measured before the change on one dashboard widget over one object declaring `close_date: date`: `close_date: 'last_30_days'` was refused while `created_at: 'last_30_days'`, `created_at: { $eq: … }` and `updated_at: { $in: [ … ] }` all passed lint and the runtime publish gate — and the engine then refused each of them with INVALID_FILTER / 400 on first render. `GraphObject.injected` becomes a `ReadonlyMap<string, GraphField>` carrying each column's definition, DERIVED from `injectedSystemColumnDefs` (the same spec tables `applySystemFields` spreads at registration) rather than transcribed, so lint cannot drift from the registry that provisions the column. The `filter-preset-comparand` oracle drops its `verdict.injected` bail: the marker says who wrote the column, the ruling turns on what it is. `id` keeps an empty slice — the driver provisions the primary key and no definition describes it. Pins the AUTHOR-FACING message verbatim, not only the graph shape, and pins the derivation against the spec table so a hand-copied type cannot pass. Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH Co-authored-by: Claude <noreply@anthropic.com>
…ne in the dotted-filter and include rules (#16340) Two more consumers of a resolved leaf's `meta` inherit the registry type the object graph now carries, which is why the fix went to the seam rather than into `filter-preset-comparand`. `list-view-field-dotted` refuses `created_at.x` (a `datetime` scalar has nothing beneath it) and `owner_id.name` (a `lookup` stores an id, not an embedded document) — both already refused at the door by `assertFilterIsMaterializable` with the registry's field map in hand, so the linter's silence was the miss. `dataset-include-unknown` drops its `verdict.injected` bail, whose stated reason ("its type is registry-owned and invisible here") this change makes false: `include: ['owner_id']` joins, `include: ['created_at']` derives no join and is refused. `id` falls through the untyped branch of all three rules — the driver provisions the primary key and no definition table describes it. Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 4 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin ffd733dd6cd67249427e0abe64dbfd94372d46ef && git checkout ffd733dd6cd67249427e0abe64dbfd94372d46ef
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 92242041e738eec91b2ccd4e29c845682b53bb4a d77c15a92d41ad1822623e2cbd9bfab71ad97cd3 && git checkout -B drift-repro 92242041e738eec91b2ccd4e29c845682b53bb4a && git merge --no-ff d77c15a92d41ad1822623e2cbd9bfab71ad97cd3
node scripts/docs-audit/affected-docs.mjs --json 92242041e738eec91b2ccd4e29c845682b53bb4a
|
Fixes #16340
Clause-②: no — nothing in this PR moves
packages/spec/src. The card proposed extendingresolveInjectedSystemColumns's plan to carry each injected column's type; that derivation already exists one module over asinjectedSystemColumnDefs(packages/spec/src/data/injected-system-column-provenance.ts, the WHAT-half #8116 moved into the spec), so the fix is entirely a consumer change inside@objectstack/lint. The spec's authorable surface is byte-unchanged; the lint package is not a published authoring surface and a rule seeing MORE columns refuses nothing new at the spec door.Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH
The author-visible symptom, measured first
resolveFieldPathalready returns{ kind: 'ok', injected: true }for an injected leaf (that is what #5378 /injectedColumnsForfixed), so no rule tells an authorcreated_atdoes not exist. The author-visible symptom is SILENCE — a missed catch, exactly the failure direction the card's own "Measured" section states.Every
AUTHORING_RULESentry driven over one stack — one object declaringclose_date: date/stage: select, authoring nocreated_at, and one dashboard bound to it through a dataset — onorigin/maind57611dfd3:close_date: 'last_30_days'(authoreddate)created_at: { $gte: 'last_30_days' }(ordering — arm 1, field-agnostic)created_at: 'last_30_days'created_at: { $eq: 'last_30_days' }updated_at: { $in: ['last_30_days'] }stage: 'this_quarter'(aselectcolumn)Whole-run diff: 10 findings before, 13 after, and the diff of the two finding sets is exactly those three additions — nothing removed, nothing else added.
Before, verbatim: no finding of any rule, at any severity, on any of the three rows. There is no sentence to quote.
After, verbatim (
dashboards[0].widgets[0].filter.created_at, severityerror, rulefilter-preset-comparand):That exact string is pinned in
validate-preset-comparands.test.tswithtoBe, nottoContain— ⭐ a pin on the graph shape alone would have gone green while the author still received nothing.Failure-fix order: route 1 — the construct is deleted
The construct that permitted the error was
GraphObject.injectedbeing a name-only set, which forcedFieldPathVerdictto hand back anokleaf with nometaand every consumer to carry averdict.injectedbail meaning "unanswerable". That set is gone:injectedis now a aReadonlyMapkeyed by column name whose value is aGraphFieldcarrying each column's registry definition, and the bails deleted themselves — no new check was added anywhere.resolveInjectedSystemColumns(viainjectedColumnsFor) and the slice frominjectedSystemColumnDefs(via the newinjectedColumnDefsFor) — two derivations over ONE plan, both the spec tablesapplySystemFieldsspreads at registration. No "created_at is a datetime" is written anywhere inpackages/lint.object-graph.test.tsasserts the graph's type equals the spec table'stypeby reading the table back, so a hand-copied literal could not pass that pin.idis the single addressable column with no definition behind it — the DRIVER provisions the primary key — so its slice is empty. That is the truthful answer, and all three rules fall through their untyped branch there rather than inventing a verdict.Two more rules inherit it, which is why the fix went to the seam
list-view-field-dotted—created_at.xnow reads as thedatetimescalar it is andowner_id.nameas thelookupit is.assertFilterIsMaterializableand the REST ingress have always answered400 INVALID_FIELDfor both; the linter was silent only because the type was missing here. (This one surfaced as a RED test in the package suite, not as a choice.)dataset-include-unknown— itsverdict.injectedbail is deleted along with the comment that justified it ("its type is registry-owned and invisible here"), which this change makes false.include: ['owner_id']joins;include: ['created_at']derives no join and is refused.A relationship hop through an injected column stays
unknowable/injected-hopdeliberately: the slice now carriesreference, and traversing it would newly judge every path through a platform anchor whereversys_useris compiled into the stack. That is a widening with its own findings to measure, and the docblocks say so rather than repeating the retired "invisible here" claim.The claim declared
object-graph.ts+injected-system-columns.ts+ tests. The real face is below;injected-system-columns.tsis untouched and three more lint files are in.validate-list-view-field-refs.tsis also touched by open PR #17298 (a 3-line docblock edit at lines 190-197; mine is the adjacent skips paragraph at 203-210).git merge-tree --write-tree HEAD refs/pull/17298/headexits 0 with no conflicted path. The other four lane PRs (#17146, #17249, #17358, #17401) hold none of my paths — checked by diffing all five PR refs againstorigin/main, 86 busy paths, the same count the claim comment measured.Verification
Ablation — two legs, both predicted RED before running, both restored and proved restored. Same-package relative imports, so no
distis in the resolution path; each leg proved the mutation reached disk by blob hash againstHEAD:PATHbefore the run, and each restore leg isgit checkout HEAD -- PATHproved by an emptygit diff HEAD.verdict.injectedbail in the oracleinjectedColumnDefsForto an empty mapTests (final commit
d77c15a92d):pnpm --filter @objectstack/lint test:: exit 0 — 103 files, 3729 tests passed (3724 before this PR).pnpm --filter @objectstack/lint typecheck:: exit 0.pnpm --filter '@objectstack/lint...' build:: exit 0.npx eslint . --no-inline-config --format json:: exit 0 — 6552 files linted (population read from eslint's own config), 0 errors, 0 warnings. The whole tree, not a narrowing.pnpm check:nul-bytes:: exit 0; plus a direct control-byte scan of all 10 changed paths with a lit fixture (probe fires, exit 0) and a dark fixture (probe silent, exit 1).Gates —
node scripts/pm/dispatch-gates.mjsderives 59 families for this change set; all 59 run, and re-deriving after the final commit adds none. Every exit code was captured before any pipe.pnpm check:dual-build-cjs-loads:: exit 3,pnpm check:type-check-debt:: exit 3 — PREREQUISITE NOT MET (both read a whole-repodist/). NOT MEASURED, neither pass nor fail; left to CI.pnpm check:docs-transcript-drift:: exit 3 unbuilt, then exit 0 afterpnpm --filter '@objectstack/lint...' build.pnpm check:lean-entry-closure:: exit 3 — PREREQUISITE NOT MET (@objectstack/objectql/coredist absent). NOT MEASURED; left to CI.origin/mainand that 3 files it reads changed upstream (scripts/check-test-source-alias.mjsamong them). The derived family list is byte-identical before and aftergit fetch origin main, so nothing here narrows on the stale copy — but CI is the authority on the merged tree.🤖 Generated with Claude Code
https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH
Generated by Claude Code