Skip to content

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

Merged
os-bill merged 2 commits into
mainfrom
claude/issue-16340-lint-injected-temporal-columns
Sep 10, 2026
Merged

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
os-bill merged 2 commits into
mainfrom
claude/issue-16340-lint-injected-temporal-columns

Conversation

@os-bill

@os-bill os-bill commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator

Fixes #16340

Clause-②: no — nothing in this PR moves packages/spec/src. The card proposed extending resolveInjectedSystemColumns's plan to carry each injected column's type; that derivation already exists one module over as injectedSystemColumnDefs (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

⚠️ The dispatch framed this as a FALSE POSITIVE — "it tells them a field does not exist when the registry injects it". That is falsified. resolveFieldPath already returns { kind: 'ok', injected: true } for an injected leaf (that is what #5378 / injectedColumnsFor fixed), so no rule tells an author created_at does not exist. The author-visible symptom is SILENCE — a missed catch, exactly the failure direction the card's own "Measured" section states.

Every AUTHORING_RULES entry driven over one stack — one object declaring close_date: date / stage: select, authoring no created_at, and one dashboard bound to it through a dataset — on origin/main d57611dfd3:

authored filter before after
close_date: 'last_30_days' (authored date) refused refused
created_at: { $gte: 'last_30_days' } (ordering — arm 1, field-agnostic) refused refused
created_at: 'last_30_days' silent refused
created_at: { $eq: 'last_30_days' } silent refused
updated_at: { $in: ['last_30_days'] } silent refused
stage: 'this_quarter' (a select column) silent silent

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, severity error, rule filter-preset-comparand):

"last_30_days" is a dashboard date-range PRESET name, not a filter value. It is only understood by the dashboard date-filter positions (dateRange.defaultRange, a date global filter's defaultValue), where the console lowers it to {date-macro} bounds before querying. As a bare "$eq" comparand nothing resolves it: a declared datetime/date field refuses the query at the engine (INVALID_FILTER / 400), and any other column compares the literal string. Write the date-macro window instead — e.g. { $gte: '{30_days_ago}' } — or an ISO date such as "2026-01-15". Refused at authoring time so the error surfaces where the filter is written.

hint: Presets belong to the dashboard date-filter bar (dateRange.defaultRange, a date global filter's defaultValue). In a filter comparand, write the {date-macro} window the message names, or an ISO date.

That exact string is pinned in validate-preset-comparands.test.ts with toBe, not toContain — ⭐ 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.injected being a name-only set, which forced FieldPathVerdict to hand back an ok leaf with no meta and every consumer to carry a verdict.injected bail meaning "unanswerable". That set is gone: injected is now a a ReadonlyMap keyed by column name whose value is a GraphField carrying each column's registry definition, and the bails deleted themselves — no new check was added anywhere.

⚠️ Derived, not transcribed. Membership comes from resolveInjectedSystemColumns (via injectedColumnsFor) and the slice from injectedSystemColumnDefs (via the new injectedColumnDefsFor) — two derivations over ONE plan, both the spec tables applySystemFields spreads at registration. No "created_at is a datetime" is written anywhere in packages/lint. object-graph.test.ts asserts the graph's type equals the spec table's type by reading the table back, so a hand-copied literal could not pass that pin.

id is 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-dottedcreated_at.x now reads as the datetime scalar it is and owner_id.name as the lookup it is. assertFilterIsMaterializable and the REST ingress have always answered 400 INVALID_FIELD for 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 — its verdict.injected bail 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-hop deliberately: the slice now carries reference, and traversing it would newly judge every path through a platform anchor wherever sys_user is 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.

⚠️ Declared file face — larger than the claim comment's

The claim declared object-graph.ts + injected-system-columns.ts + tests. The real face is below; injected-system-columns.ts is untouched and three more lint files are in.

packages/lint/src/object-graph.ts                  (the construct)
packages/lint/src/system-fields.ts                 (the derivation)
packages/lint/src/validate-preset-comparands.ts    (the card's rule)
packages/lint/src/validate-list-view-field-refs.ts (inherited — forced by a red test)
packages/lint/src/validate-dataset-references.ts   (inherited — stale bail this change falsifies)
+ the four matching *.test.ts, + .changeset/

validate-list-view-field-refs.ts is 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/head exits 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 against origin/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 dist is in the resolution path; each leg proved the mutation reached disk by blob hash against HEAD:PATH before the run, and each restore leg is git checkout HEAD -- PATH proved by an empty git diff HEAD.

leg mutation result
A reinstate the verdict.injected bail in the oracle 3 failed / 46 passed — the message pins fell
B starve injectedColumnDefsFor to an empty map 5 failed / 44 passed — the derivation pin fell too

Tests (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).

Gatesnode scripts/pm/dispatch-gates.mjs derives 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.

  • 55 :: exit 0.
  • pnpm check:dual-build-cjs-loads :: exit 3, pnpm check:type-check-debt :: exit 3 — PREREQUISITE NOT MET (both read a whole-repo dist/). NOT MEASURED, neither pass nor fail; left to CI.
  • pnpm check:docs-transcript-drift :: exit 3 unbuilt, then exit 0 after pnpm --filter '@objectstack/lint...' build.
  • pnpm check:lean-entry-closure :: exit 3 — PREREQUISITE NOT MET (@objectstack/objectql/core dist absent). NOT MEASURED; left to CI.

⚠️ The derivation warns the tree is 8 commits behind origin/main and that 3 files it reads changed upstream (scripts/check-test-source-alias.mjs among them). The derived family list is byte-identical before and after git 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

…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>
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/lint, touching 10 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/lint/src/validate-list-view-field-refs.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

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

  • content/docs/deployment/validating-metadata.mdx (via last_30_days (literal, a string literal on a changed line))
  • content/docs/ui/dashboards.mdx (via last_30_days (literal, a string literal on a changed line), this_quarter (literal, a string literal on a changed line))
What this run could not see
  • 1 changed file(s) yielded no anchor (packages/lint/src/validate-list-view-field-refs.ts) — pages documenting those are invisible to this run
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 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; 100 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.

Coarse fallback — 4 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 92242041e738eec91b2ccd4e29c845682b53bb4apackageMentionDocs.

Which tree this was computed on

This run read content/docs from ffd733dd6cd67249427e0abe64dbfd94372d46ef — the merge of head d77c15a92d41ad1822623e2cbd9bfab71ad97cd3 into base 92242041e738eec91b2ccd4e29c845682b53bb4a, 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 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

⚠️ 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 92242041e738eec91b2ccd4e29c845682b53bb4a → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Sep 10, 2026
@os-bill
os-bill marked this pull request as ready for review September 10, 2026 13:55
@os-bill
os-bill enabled auto-merge September 10, 2026 13:56
@os-bill
os-bill added this pull request to the merge queue Sep 10, 2026
Merged via the queue into main with commit 31064ca Sep 10, 2026
35 checks passed
@os-bill
os-bill deleted the claude/issue-16340-lint-injected-temporal-columns branch September 10, 2026 14:36
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/l tests tooling

Projects

None yet

2 participants