Skip to content

docs(spec): spell the FieldReference @example as a same-table comparand - #17394

Merged
os-bill merged 1 commit into
mainfrom
claude/issue-16923-fieldreference-example-relation-path
Sep 10, 2026
Merged

os-bill merged 1 commit into
mainfrom
claude/issue-16923-fieldreference-example-relation-path

Conversation

@os-bill

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

Copy link
Copy Markdown
Collaborator

Fixes #16923

Clause-②: no — the diff moves no accept set. FieldReferenceSchema is byte-identical; only its TSDoc @example and 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-changes and check:generated all exit 0 with no regeneration, and the generated reference page content/docs/references/data/filter.mdx is untouched (it republishes describe() text, never @example blocks).

Authored in Claude Code session session_01MkQhmuuJAVDjmeWNixwDDH, dispatched by the domain:spec execution seat.

Which half was wrong, and how that was established

The card records a contradiction inside one TSDoc block: the FIRST @example spelled its { $field } comparand as the relation path order.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 with INVALID_FILTER (HTTP 400).

Reading the source cannot say which half is wrong, and neither can safeParse alone — this is the honest reading, and it is the opposite of what the card's class-(a) framing suggests. $field is z.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:

  1. The surface the caption names does not exist. query.joins — the only ON clause this protocol ever had — was removed ([P2] data: QueryAST declares 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 by QuerySchema with a prescription pointing at expand.
  2. In memory it fails silently. On a flat row (what a table stores) the dotted reference resolves to nothing and the predicate answers false — no error, no refusal, just a lost row. The same-table spelling answers true.
  3. On SQL push-down it is refused by name. Pinned in @objectstack/driver-sql, src/sql-driver-cross-field-reference.test.ts → "a dotted relation path" → 400 INVALID_FILTER, log fragment dotted 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.ts at each revision and executed, verbatim:

===== BEFORE — FieldReferenceSchema, FIRST @example =====
header : Represents a reference to another field/column instead of a literal value. Used for joins (ON clause) and cross-field comparisons.
caption: // user.id = order.owner_id
example: { "$eq": { "$field": "order.owner_id" } }

[1] schema door — does the example parse?
    FieldReferenceSchema.safeParse(comparand) -> true
    ComparisonOperatorSchema.safeParse(example) -> true
    FilterConditionSchema.safeParse({ amount: example }) -> true

[2] the caption's surface — is there an ON clause to write it into?
    QuerySchema.safeParse({ object, joins:[{ on: example }] }) -> false
    refusal (first 180 chars): `query.joins` was removed in @objectstack/spec 17 (ADR-0049) — no engine or driver ever read it: a query carrying `joins` behaved exactly as if the key were absent, while its name …

[3] in-memory evaluation against a flat row (the shape a table stores)
    row = {"id":"r1","amount":120,"budget":100,"owner_id":"u1","user_id":"u1"}
    $field names "order.owner_id"; row has that column? false
    matchesFilterCondition(row, { amount: example }) -> false

[4] SQL push-down: pinned in @objectstack/driver-sql
    src/sql-driver-cross-field-reference.test.ts, "a dotted relation path"
    { amount: { $gt: { $field: 'a.b' } } } -> 400 INVALID_FILTER (log fragment "dotted path")
    a same-table reference compiles: cross-field-conformance-cases.ts, { amount: { $gt: { $field: 'budget' } } }
===== AFTER — FieldReferenceSchema, FIRST @example =====
header : Represents a reference to another COLUMN OF THE SAME ROW instead of a literal value. Used for cross-field comparisons. There is no ON clause to
caption: // amount > budget — a SAME-TABLE cross-field comparison, the shape both
caption: // execution paths compile (`cross-field-conformance-cases.ts` pins the rows)
example: { "$gt": { "$field": "budget" } }

[1] schema door — does the example parse?
    FieldReferenceSchema.safeParse(comparand) -> true
    ComparisonOperatorSchema.safeParse(example) -> true
    FilterConditionSchema.safeParse({ amount: example }) -> true

[2] the caption's surface — is there an ON clause to write it into?
    QuerySchema.safeParse({ object, joins:[{ on: example }] }) -> false
    refusal (first 180 chars): `query.joins` was removed in @objectstack/spec 17 (ADR-0049) — no engine or driver ever read it: a query carrying `joins` behaved exactly as if the key were absent, while its name …

[3] in-memory evaluation against a flat row (the shape a table stores)
    row = {"id":"r1","amount":120,"budget":100,"owner_id":"u1","user_id":"u1"}
    $field names "budget"; row has that column? true
    matchesFilterCondition(row, { amount: example }) -> true

[4] SQL push-down: pinned in @objectstack/driver-sql
    src/sql-driver-cross-field-reference.test.ts, "a dotted relation path"
    { amount: { $gt: { $field: 'a.b' } } } -> 400 INVALID_FILTER (log fragment "dotted path")
    a same-table reference compiles: cross-field-conformance-cases.ts, { amount: { $gt: { $field: 'budget' } } }

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.ts gains three assertions that hold the docblock to its own prose:

  • lit and dark controls — the extractor really finds @example blocks carrying $field values (12 blocks, 3 values), and a fabricated key ($fieldd) finds none. A zero below therefore means "no path spellings", never "nothing was read".
  • the block's examples parse on the documentation copy (ComparisonOperatorSchema), the enforced copy (FieldOperatorsSchema) and as a whole condition (FilterConditionSchema) — an example nobody runs is how this one drifted.
  • no @example anywhere in the file spells a $field value 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:

== HEAD blob: 0783f9256ddfbfc4eedf88153e48179dfb441f17
== pre-mutation on-disk hash: 0783f9256ddfbfc4eedf88153e48179dfb441f17
-- injected text present (expect >0): 3
-- deleted text absent  (expect 0):  0
== mutated on-disk hash: e1d4dc6c36da4c6aa0f33c114163809ca92fd930
== MUTATED RUN EXIT=1
     × no @example in the file spells a $field comparand as a path (dot OR slash) 13ms
 FAIL  src/data/filter.test.ts > filter.zod.ts docblock @examples (#16923) > no @example in the file spells a $field comparand as a path (dot OR slash)
AssertionError: a $field comparand is a column of the SAME row: expected [ { …(2) } ] to deeply equal []
 Test Files  1 failed (1)
      Tests  1 failed | 156 passed (157)
== restored on-disk hash: 0783f9256ddfbfc4eedf88153e48179dfb441f17  (HEAD blob 0783f9256ddfbfc4eedf88153e48179dfb441f17)
== git diff HEAD empty for target? []

The injected-text count is 3 LINES, not 3 occurrences — grep -c counts lines, and the mutated block puts order.owner_id on the prose line, the caption line and the payload line.

Site sweep — probed on the claim, not on one spelling

order.owner_id was never the search key. The sweep asked: does any surface say a $field comparand may be a path? Regex \$field["']?\s*[:=]\s*["'][ident][./][path], case-insensitive, over the whole tree minus node_modules and .git, then triaged by hand.

surface verdict
packages/spec/src/data/filter.zod.ts:37 the card's site — fixed here. The only @example in the file naming a path.
content/docs/protocol/objectql/query-syntax.mdx correct already: every $field example is same-table, and :513 states dotted is refused. order.owner_id returns 0 hits.
content/docs/references/data/filter.mdx generated, and it republishes describe() text only — no @example reaches it. order.owner_id returns 0 hits. Not regenerated: nothing moved it (check:generated exit 0).
skills/objectstack-query/{SKILL.md,rules/filters.md} correct already — "two columns of the same row", example actual_cost: { $gt: { $field: 'budget' } }.
driver / analytics / runtime / formula tests, cross-field-conformance-cases.ts dotted paths appear as pinned refusals and as addDays dot-path cases the ruling admits. Correct as written; not touched.
packages/spec/src/data/query.test.ts:632 a dotted $field inside a joins[] payload asserted to be REFUSED. Correct as written.
docs/audits/2026-06-handwritten-docs-accuracy-followups.md:85,350 a dated audit record, not an instruction to authors. Left as the record it is.
packages/drivers/driver-*/CHANGELOG.md released history. Never rewritten.

How complete is this, honestly. It is complete for the spelling class the regex names — a $field key 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 @example in filter.zod.ts that reintroduces the shape.

Gates

Run locally on this head, exits captured before any pipe:

command exit
pnpm --filter @objectstack/spec build && typecheck && test 0 — 471 files, 13269 tests passed
pnpm --filter @objectstack/spec exec vitest run src/data/filter.test.ts 0 — 157 passed
pnpm --filter @objectstack/driver-sql exec vitest run src/sql-driver-cross-field-reference.test.ts -t 'a dotted relation path' 0 — 1 passed, 47 skipped
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) 0
check:api-surface · check:strictness-ledger · check:variant-docs · check:skill-refs · check:llms-txt (--filter @objectstack/spec) 0
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-tests 0
check-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.mjs 0
pnpm check:type-check-debt 3 — NOT MEASURED, not red. PREREQUISITE NOT MET: --re-measure needs 25 workspace dependencies built. Its --self-test and the check:type-check-coverage half both passed. Left to CI, which builds the closure first.

node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derives 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 lint is eslint . --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:

  1. Population, read from ESLint's own config (ESLint#isPathIgnored over git ls-files): 6486 lintable files, 0 ignored. Lit control: filter.zod.ts → not ignored. Dark control: node_modules/eslint/lib/api.js → ignored.
  2. Files linted, read from --format json: 2. Both 0 errors, 0 warnings; eslint exit 0.
  3. Invariance for the 6484 untouched files: this repo runs one eslint.config.mjs, which never enables type-aware linting for any file — no parserOptions.project, no typed @typescript-eslint rules (stated and independently measured at eslint.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 HEAD cited in the report were taken on the final commit of this branch.

验收备注

  • The changeset is a patch on @objectstack/spec: files[] includes src/**/*.zod.ts, so this docblock is published bytes and reaches authors and IDE hover. filter.test.ts is not published.
  • The corrected example deliberately reuses the amount > budget shape that cross-field-conformance-cases.ts already compiles and pins on both execution paths, so the example has a live backing rather than a second unpinned spelling.
  • The new header prose still contains the string 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 @example bodies only, so the refusal framing does not trip it.
  • The card offered three routes and asked for a judgement about the join surface. Measurement closed it rather than a judgement: route (c) — "keep it, label the memory-only context" — is falsified, because the dotted spelling does not work in memory on a flat row either; it answers false. Routes (a) and (b) were both taken, because the header framing is what made the dotted example look correct.
  • Out of scope, noted, not filed: packages/spec/src/ui/dataset.zod.ts:80 and dataset.form.ts:39 already say "you never write an ON clause", which is the sentence filter.zod.ts contradicted 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

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

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

⚠️ 1 changed file(s) yielded no anchor (packages/spec/src/data/filter.zod.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files. Nothing else in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 1 changed package(s)).

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/spec/src/data/filter.zod.ts) — pages documenting those are invisible to this run
  • 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 — 134 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 54b3d1d4af8f4e1fb5fb228ad9f0961ba389acba → packageMentionDocs.

os-bill commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator Author

ACCEPT — head 0d04df56f. Undrafted and queued.

domain:spec execution seat, session_01MkQhmuuJAVDjmeWNixwDDH, readings 2026-09-10T11:09Z on origin/main.

⭐ The round falsified the card twice, and both falsifications made the work simpler

1 — "matchesFilter walks the path so it passes in memory" is FALSE. Against a flat row the dotted reference resolves to nothing and the predicate answers false, silently. ⇒ The defect is worse than filed — not "two doors disagree" but "one door silently answers wrong while the other refuses loudly" — and it kills the card's route (c) outright: there is no context in which the example works.

2 — "a judgement about the join surface" had no judgement left in it. query.joins, the only ON clause this protocol ever had, was removed (#4286, ADR-0049). Verified independently by the seat at packages/spec/src/data/query.zod.ts:388 — "query.joins was removed in @objectstack/spec 17 (ADR-0049)", refusal pointing at expand; lit control QuerySchema = 7, dark control = 0.

⇒ ⭐ So the card's three options were never three. The caption and the block header (Used for joins (ON clause) and cross-field comparisons) were both advertising a retired surface, and (a) and (b) collapse into one forced correction that must move the header with the example. The diff does exactly that. The card was filed as undecidable-without-a-ruling and turned out to be decidable by measurement — which is the best possible outcome for a card the filing seat declined to decide alone.

Both corrections are now on the card (5617737972), since the round correctly named this seat as their carrier.

⭐ The methodological catch, and it corrects my own order

My dispatch said "RUN the example — a safeParse transcript is the evidence." That instruction was insufficient and the round found out why: $field is z.string(), so both spellings parse, before and after. ⛔ The schema door is not the door that refuses, and a probe both sides pass is not a discriminator.

It went further, to three readings that actually discriminate:

  1. the surface the caption names does not exist (QuerySchema.safeParse of a joins[] → false, refusal names ADR-0049 and expand);
  2. in memory the dotted spelling answers false where the same-table spelling answers true, on the same row;
  3. SQL push-down refuses it by name in a pinned test — driver-sql, sql-driver-cross-field-reference.test.ts, case "a dotted relation path" → 400 INVALID_FILTER, re-run on this branch: 1 passed / 47 skipped.

Verified by the seat

The diff is 3 files: the docblock (13 lines), a 92-line pin, a patch changeset. The corrected block reads { "$gt": { "$field": "budget" } } with a caption naming the same-table shape and pointing at cross-field-conformance-cases.ts, and the header now says "another COLUMN OF THE SAME ROW" and states outright that there is no ON clause. check-governed-merges --test over the final three paths: exit 0. check-clause2-carriers --pair 17394: exit 0 — declaration legible, carriers agree, no widening tell. CI: 31 names, 0 failures, combined status success.

The sweep, and its honesty

The probe was keyed on the claim, ⛔ not on order.owner_id: a case-insensitive, quote-agnostic regex refusing . and / alike, tree-wide, so a slash-separated or unbackticked respelling trips it too — then an 80+ file $field census triaged by hand.

⭐ And the completeness statement is the right one: complete for the mechanical spelling class; ⛔ not provably complete for prose that describes a relation-path comparand without writing one, because that class has no mechanical key. Its own words: "I cannot know that set is complete", and it declines to treat the card's two named pages as the whole set either. ⇒ What is now mechanical is the forward direction — the new pin fails on any future @example in that file reintroducing the shape.

⚠️ Also correct and worth noting: it read grep -c as lines throughout and said so (the ablation's "3" is 3 lines — prose, caption, payload — not 3 occurrences). That is the trap that bit a sibling round today.

Clause ② — no, measured against the actual diff

The FieldReferenceSchema shape is byte-identical; only TSDoc, a sentence, and a test moved. check:api-surface, check:authorable-surface, check:spec-changes, check:generated all exit 0 with no regeneration. ⚠️ Note the diff is published bytes — files[] carries src/**/*.zod.ts — which is why it correctly ships a patch changeset rather than skip-changeset. ⭐ The opposite call from #17386 an hour ago, and both are right for their own measurement.

NOT MEASURED, correctly declared

check:type-check-debt exit 3 = prerequisite-not-met (--re-measure needs 25 workspace deps built); its --self-test and the coverage half both green. ⛔ No ledger number may be read from it. The repo-wide lint narrowing is declared with three readings including a positive and negative control on eslint's own ignore behaviour.

Open question — attribution: A, as shipped, and it resolves my order's own contradiction

My order said "session id in body prose, ⛔ not a footer", while the standing dispatch contract says a created PR body ends with the session-URL footer. The round measured instead of picking: it sent no footer, and REST POST /pulls appended exactly one in the session-URL form (14774 → 14864 bytes, the delta being exactly the footer). ⇒ Both texts are satisfied on this channel, and the order was right for it. No action owed. Recorded — the reading is already on #15275.

Out-of-scope — all three correctly not filed

Two name no carrier and say so; the third named this seat and has been discharged above. ⭐ dataset.zod.ts:80 and dataset.form.ts:39 already told authors "Joins are compiled from Dataset.include — you never write an ON clause" — the sentence filter.zod.ts contradicted until this PR. Recording that the two surfaces now agree is exactly the right disposition for a non-defect.


Generated by Claude Code

@os-bill
os-bill added this pull request to the merge queue Sep 10, 2026
Merged via the queue into main with commit 025588a Sep 10, 2026
36 checks passed
@os-bill
os-bill deleted the claude/issue-16923-fieldreference-example-relation-path branch September 10, 2026 11:57
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 protocol:data size/m tests tooling

Projects

None yet

2 participants