docs(spec): spell the FieldReference @example as a same-table comparand - #17394
Conversation
`FieldReferenceSchema`'s first `@example` spelled its `{ $field }` comparand as
the relation path `order.owner_id`, captioned as a join ON clause, while the
same docblock's "Execution support" prose says a dotted path is refused by SQL
push-down with `INVALID_FILTER`. Running it establishes which half was wrong:
the schema admits either spelling, the memory evaluator answers `false` for the
dotted one on a flat row, the SQL compiler refuses it, and the ON clause the
caption framed it as no longer exists (`query.joins` was removed in #4286).
The example is now the same-table comparison both execution paths compile, and
the block header no longer advertises a join surface. A pin holds the block's
examples runnable and holds every `@example` in the file to a same-table
`$field` value, probing on the claim rather than on one spelling: it is
case-insensitive, quote-agnostic, and refuses `.` and `/` alike.
Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH
Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check
What this run could not see
Coarse fallback — 134 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): |
ACCEPT — head
|
Fixes #16923
Clause-②: no — the diff moves no accept set.
FieldReferenceSchemais byte-identical; only its TSDoc@exampleand the sentence above it changed, plus a new pin. Measured against the actual diff, not asserted at dispatch time:pnpm --filter @objectstack/spec run check:api-surface,check:authorable-surface,check:spec-changesandcheck:generatedall exit 0 with no regeneration, and the generated reference pagecontent/docs/references/data/filter.mdxis untouched (it republishesdescribe()text, never@exampleblocks).Authored in Claude Code session
session_01MkQhmuuJAVDjmeWNixwDDH, dispatched by thedomain:specexecution seat.Which half was wrong, and how that was established
The card records a contradiction inside one TSDoc block: the FIRST
@examplespelled its{ $field }comparand as the relation pathorder.owner_id, captioned as a join ON clause, while the same block's "Execution support" prose says a dotted path is refused by SQL push-down withINVALID_FILTER(HTTP 400).Reading the source cannot say which half is wrong, and neither can
safeParsealone — this is the honest reading, and it is the opposite of what the card's class-(a) framing suggests.$fieldisz.string(): both spellings parse, before and after. The schema door is not the door that refuses. Three runtime readings settle it instead, and all three convict the example:query.joins— the only ON clause this protocol ever had — was removed ([P2] data:QueryASTdeclares 12 members no executor runs — the liveness ledger governs metadata types, not the request surface #4286, ADR-0049). A query carrying the example's caption is refused byQuerySchemawith a prescription pointing atexpand.false— no error, no refusal, just a lost row. The same-table spelling answerstrue.@objectstack/driver-sql,src/sql-driver-cross-field-reference.test.ts→ "a dotted relation path" →400 INVALID_FILTER, log fragmentdotted path. Re-run on this branch:Tests 1 passed | 47 skipped.So the prose is right and the example was wrong — and the header sentence that made it look right ("Used for joins (ON clause) and cross-field comparisons") was advertising a retired surface. Both are corrected; the schema shape is not touched.
Before / after — the example run exactly as the file spells it
Extracted from
packages/spec/src/data/filter.zod.tsat each revision and executed, verbatim:Line
[2]is a control, not a claim about the fix: the join surface is gone in BOTH states. What changed is that the example no longer advertises it.The pin, and the ablation that proves it can fail
packages/spec/src/data/filter.test.tsgains three assertions that hold the docblock to its own prose:@exampleblocks carrying$fieldvalues (12 blocks, 3 values), and a fabricated key ($fieldd) finds none. A zero below therefore means "no path spellings", never "nothing was read".ComparisonOperatorSchema), the enforced copy (FieldOperatorsSchema) and as a whole condition (FilterConditionSchema) — an example nobody runs is how this one drifted.@exampleanywhere in the file spells a$fieldvalue as a path. The probe is on the CLAIM, not on one spelling: case-insensitive, quote-agnostic, and it refuses.and/alike, so a slash-separated or unbackticked respelling trips it too.Ablation — the pre-fix example put back byte-for-byte, on-disk arrival proved before the run, restored after:
The injected-text count is 3 LINES, not 3 occurrences —
grep -ccounts lines, and the mutated block putsorder.owner_idon the prose line, the caption line and the payload line.Site sweep — probed on the claim, not on one spelling
order.owner_idwas never the search key. The sweep asked: does any surface say a$fieldcomparand may be a path? Regex\$field["']?\s*[:=]\s*["'][ident][./][path], case-insensitive, over the whole tree minusnode_modulesand.git, then triaged by hand.packages/spec/src/data/filter.zod.ts:37@examplein the file naming a path.content/docs/protocol/objectql/query-syntax.mdx$fieldexample is same-table, and:513states dotted is refused.order.owner_idreturns 0 hits.content/docs/references/data/filter.mdxdescribe()text only — no@examplereaches it.order.owner_idreturns 0 hits. Not regenerated: nothing moved it (check:generatedexit 0).skills/objectstack-query/{SKILL.md,rules/filters.md}actual_cost: { $gt: { $field: 'budget' } }.cross-field-conformance-cases.tsaddDaysdot-path cases the ruling admits. Correct as written; not touched.packages/spec/src/data/query.test.ts:632$fieldinside ajoins[]payload asserted to be REFUSED. Correct as written.docs/audits/2026-06-handwritten-docs-accuracy-followups.md:85,350packages/drivers/driver-*/CHANGELOG.mdHow complete is this, honestly. It is complete for the spelling class the regex names — a
$fieldkey immediately assigned a quoted value containing.or/, any quote style, any case. It is not provably complete for prose that describes a relation-path comparand without writing one; that class has no mechanical key, and I triaged it by reading every author-facing surface the count table surfaced rather than by proving a negative. The two docs pages the card names are not the whole set, and I do not claim my set is either — what I claim is that the pin now fails on any future@exampleinfilter.zod.tsthat reintroduces the shape.Gates
Run locally on this head, exits captured before any pipe:
pnpm --filter @objectstack/spec build && typecheck && testpnpm --filter @objectstack/spec exec vitest run src/data/filter.test.tspnpm --filter @objectstack/driver-sql exec vitest run src/sql-driver-cross-field-reference.test.ts -t 'a dotted relation path'check:docs·check:yaml-examples·check:authorable-surface·check:generated·check:spec-changes·check:migration-registry·check:upgrade-guide·check:liveness(all--filter @objectstack/spec)check:api-surface·check:strictness-ledger·check:variant-docs·check:skill-refs·check:llms-txt(--filter @objectstack/spec)check-spec-docblock-symbol-anchors.mjs·check:nul-bytes·check:cross-package-test-inputs·check:test-source-alias·check:published-files·check:doc-authoring·check:tier-file-adoption·check:type-check-coverage·check:changeset-gate-self-testscheck-empty-changeset --base origin/main·check-changeset-no-major --base origin/main·check-closing-keyword-parity·check-comment-mask-adoption·docs-audit/check-affected-docs.mjspnpm check:type-check-debtPREREQUISITE NOT MET:--re-measureneeds 25 workspace dependencies built. Its--self-testand thecheck:type-check-coveragehalf both passed. Left to CI, which builds the closure first.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderives 81 runnable commands for this change set (plus 46 artifact-roster families, 11 wide-population families and 5 path-scheduled CI jobs it explicitly declines to place). The families above are the ones this diff can actually move; the remainder is declared to CI, which runs the whole farm.Lint, narrowed and declared.
pnpm lintiseslint . --no-inline-config, a whole-repo run CI owns. Narrowed here to the diff's two source files, with the three readings that make a narrowing a measurement rather than a skip:ESLint#isPathIgnoredovergit ls-files): 6486 lintable files, 0 ignored. Lit control:filter.zod.ts→ not ignored. Dark control:node_modules/eslint/lib/api.js→ ignored.--format json: 2. Both 0 errors, 0 warnings; eslint exit 0.eslint.config.mjs, which never enables type-aware linting for any file — noparserOptions.project, no typed@typescript-eslintrules (stated and independently measured ateslint.config.mjs:326-329). This diff changes no config file, so the ruleset applied to every untouched file is byte-identical and no verdict on one can depend on my two files' contents.Every gate figure above and the
git rev-parse --short HEADcited in the report were taken on the final commit of this branch.验收备注
patchon@objectstack/spec:files[]includessrc/**/*.zod.ts, so this docblock is published bytes and reaches authors and IDE hover.filter.test.tsis not published.amount > budgetshape thatcross-field-conformance-cases.tsalready compiles and pins on both execution paths, so the example has a live backing rather than a second unpinned spelling.order.owner_id, once, framed as the refused spelling. That is deliberate — a reader who already copied the old example needs to find the correction by searching for what they copied — and the new pin scans@examplebodies only, so the refusal framing does not trip it.false. Routes (a) and (b) were both taken, because the header framing is what made the dotted example look correct.packages/spec/src/ui/dataset.zod.ts:80anddataset.form.ts:39already say "you never write an ON clause", which is the sentencefilter.zod.tscontradicted until this PR. They are correct; no carrier and no defect. Recorded so the next reader of this block knows the two surfaces now agree.Generated by Claude Code