Skip to content

feat(lint): ask the #8116 provenance question on the SORT axis (#10474) - #10745

Merged
os-zhuang merged 2 commits into
mainfrom
claude/issue-10474-sort-axis-provenance
Aug 21, 2026
Merged

feat(lint): ask the #8116 provenance question on the SORT axis (#10474)#10745
os-zhuang merged 2 commits into
mainfrom
claude/issue-10474-sort-axis-provenance

Conversation

@claude

@claudeclaudeBot commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Closes#10474

This card was confirming or overturning two census rulings, not fixing a failing test. Step ① (premise verification) came first and is reported in full below, because it changed one of them.


Step ① — the premise, measured end to end

The #8999 census recorded validate-sortable-fields.ts as not asking the #8116 provenance question, with the reason that an ADR-0015 external object never reaches the union branch at all — skip ② was believed to catch it.

The gap is CONFIRMED. The recorded reason is OVERTURNED.

①a — the external object does reach the union branch

declaredFieldTarget (packages/lint/src/validate-searchable-fields.ts) returns null on exactly one condition — fields missing, unreadable, or naming nothing — and nothing in it tests external. Measured by running the real index over the showcase's own federated object:

=== B. DOES THE EXTERNAL OBJECT REACH THE UNION BRANCH? ===
indexObjectSearchTargets.has("showcase_ext_customer") = true
declaredFieldTarget(external) === null (skip 2)? = false
target.names = ["email","lifetime_value","name","region"]

So the shipped shape — a federated object that declares a mapped field map, as examples/app-showcase/src/data/objects/external/customer.object.ts does — is indexed like any other object and lands squarely in skip ③. The path existed and was shipped; only the warning was missing.

①b — the axis asymmetry the card describes, on one object

Same stack, one external object and its local twin, each with a list view ordering by created_at and a searchableFields naming an anchor:

=== A. PROVENANCE FACTS ===
unprovisionedInjectedColumnsFor(showcase_ext_customer) = ["created_at","created_by","organization_id","owner_id","owning_business_unit_id","updated_at","updated_by"]
unprovisionedInjectedColumnsFor(showcase_customer) = []
SYSTEM_FIELDS.has('created_at') = true
=== C. SORT AXIS ===
validateSortableFields -> 0 finding(s)
=== D. SEARCH AXIS (identical anchor, identical object) ===
validateSearchableFields -> 1 finding(s)
warning searchable-field-unprovisioned | objects[0].searchableFields[1] | ...

①c — the runtime consequence, on a real driver over a real remote table

This is the half the card recorded as never observed. A real SqlDriver over better-sqlite3, the object declared exactly as the showcase declares it, against a remote customers table carrying [id, name, email, region, lifetime_value] and none of the seven injected anchors:

=== G. THE REMOTE TABLE ===
remote `customers` columns = ["id","name","email","region","lifetime_value"]
injected anchors on this object = ["organization_id","created_at","created_by","updated_at","updated_by","owner_id","owning_business_unit_id"]
=== H. BASELINE — ORDER BY a REAL remote column ===
orderBy name asc -> ["c1","c2","c3"]
orderBy name desc -> ["c3","c2","c1"]
=== I. ORDER BY created_at (injected, unprovisioned) ===
threw? -> NO — the read SUCCEEDED
orderBy created_at asc -> ["c1","c2","c3"]
orderBy created_at desc -> ["c1","c2","c3"]
asc === desc (byte-identical)? -> true
rows returned -> 3
=== I2. SAME, on `owner_id` ===
orderBy owner_id asc -> ["c1","c2","c3"]
orderBy owner_id desc -> ["c1","c2","c3"]
asc === desc (byte-identical)? -> true

asc and desc byte-identical while the baseline reverses is what makes it a dropped sort rather than a coincidence — the same signature this rule already records for formula, reached by a second route.

⚠️The first run of ①c was contaminated and is reported rather than quietly re-run. Seeding the remote table through the managed driver's initObjects made its DDL inject created_at/updated_at into the fixture, so the remote table under test carried the very column the measurement was about (["id","created_at","updated_at","name","email","region","lifetime_value"]) and asc/desc came back merely different, not identical. The fixture was rebuilt with raw DDL so the remote carries exactly the five columns the showcase declares. The numbers above are from the corrected run.

①d — why an authoring gate is the only door available

Both runtime doors on this axis judge formula alone (UNMATERIALIZED_SORT_TYPES):

An injected anchor is a datetime or a lookup, so it clears every verdict and reaches the driver.


Step ② — the wiring

New rule sort-field-unprovisioned, mirroring #8404's SEARCH wiring on the identical index: an optional trailing anchor-index parameter on the exported checkSortDeclaration core (same posture, same reason, as checkSearchableFieldList's — an out-of-repo caller that never built the index keeps its pre-#10474 answers), indexUnprovisionedAnchors built once in the walk, and the question asked inside the skip-③ branch about a name the union already decided not to flag.

warning, never error and never gating (#4330's cost asymmetry). The remote schema is invisible to this pass, so the remote table may genuinely carry a created_at of its own; declaring that column — the first remedy the shared hint prescribes — silences the finding, because unprovisionedInjectedColumnsFor excludes an author-declared column of the same name (#7859's security direction). The runtime publish gate splits on severity (runtime-gate.ts: errors = severity === 'error', everything else advisories), so this refuses no write.

Two deliberate narrowings, both pinned:

The rule's own skip-② docblock sentence — "external objects and datasource-introspected schemas" — was measured wrong and is corrected in place.

Ruling on validate-translation-references.ts — UPHELD, on fresh evidence

The card asked for confirm-or-overturn with fresh eyes rather than inheritance. Upheld, and the ledger why now carries the evidence rather than the assertion:

The over-inclusion cost that does exist there (a systemFields: false object, where the anchor is never injected and the translation really is an orphan) is an EXISTENCE question, not a provenance one — unprovisionedInjectedColumnsFor returns empty for that object too, so the provenance API could not close it either.

Verification

Gate union re-derived from the real changeset with node scripts/pm/dispatch-gates.mjs (no paths) and re-run on the final head f29ecbfd4b.

  • pnpm --filter @objectstack/lint exec vitest run80 files, 2246 passed, 5 skipped
  • pnpm --filter @objectstack/lint run typecheck — clean
  • Path-derived: check:changeset-gate-self-tests, check:cross-package-test-inputs, check:objectui-changeset, check:slot-lookup, check:test-source-alias, check:type-source-resolution, check-adr-0087-registration, check-changeset-no-major, check-cross-package-test-inputs, check-empty-changeset, check-affected-docs — all exit 0
  • Convention-triggered (test files edited): check:query-options-erasure, check:type-check-coverage, check:engine-double-contract, check:where-matcher — all exit 0
  • Reasoned, not derived: check:nul-bytes (any edit) — clean over 6232 files

A ratchet the green typecheck could not see.packages/lint's tsconfig excludes **/*.test.ts, so pnpm typecheck is green over code the check:type-check-coverage --re-measure program still measures. Reproducing that program's own TEST_DEBT project for this package caught the first version of the new tests moving the count 19 → 20 (one TS2352 from a tuple cast). Fixed in the second commit; re-measured at exactly 19, matching the ledger, with neither edited test file contributing an error.

Ablation. Removing only the provenance findings.push reddened exactly the 6 cases predicted (warns on a list-view sort…, covers every anchor…, reads the legacy string sort form…, is additive…, reaches the defineView aggregate…, asks once the caller passes it), and the census test stayed green as predicted — a push-only ablation leaves the provenance-API tokens in the file, which is what that ledger judges by. The four negative-direction cases stayed green, which is correct: they assert absence. Both legs are same-package relative imports resolving to src, so no dist is involved and no rebuild applies; the mutation and the restore were each confirmed on disk by anchor count (anchors?.has(head) 1→0→1, rule: SORT_FIELD_UNPROVISIONED 1→0→1, ablation marker 0→1→0).

Inert on the current corpus, by design and by scan. No object under packages/platform-objects/src declares external, and no view, page or list view anywhere in examples/ binds either of the showcase's two federated objects — so nothing shipped newly emits. That absence is also why the census could only ever record this as a code-path reading.

Out of scope, filed not fixed

Changeset

A real one, @objectstack/lint: minornotskip-changeset. The package publishes (17.1.0, no private) and this diff changes what it reports to consumers: a new finding, a new exported rule id, a new optional parameter on an exported core.


Generated by Claude Code

…atchet
The tuple cast moved packages/lint's TEST_DEBT count 19 -> 20 (TS2352). The
package tsconfig excludes *.test.ts, so `pnpm typecheck` could not see it —
only the check:type-check-coverage --re-measure program can.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DdCnBGcHeufjrq7drTD3wt
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

5 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. ✅

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/lint/src/index.ts) — pages documenting those are invisible to this run
  • the SDK route bridge reached 45 of 221 client-bound route-ledger rows — the other 176 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run: node scripts/docs-audit/affected-docs.mjs --bridge-coverage

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 9185ff0213c1590bf3808d5955cddd2becf924eapackageMentionDocs.

Which tree this was computed on

This run read content/docs from 98ac98ebdf6d510b07fc1e5aee44d8cacc14610c — the merge of head f29ecbfd4b13be7f8c776355fc4dff3259974895 into base 9185ff0213c1590bf3808d5955cddd2becf924ea, 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 98ac98ebdf6d510b07fc1e5aee44d8cacc14610c && git checkout 98ac98ebdf6d510b07fc1e5aee44d8cacc14610c
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 9185ff0213c1590bf3808d5955cddd2becf924ea f29ecbfd4b13be7f8c776355fc4dff3259974895 && git checkout -B drift-repro 9185ff0213c1590bf3808d5955cddd2becf924ea && git merge --no-ff f29ecbfd4b13be7f8c776355fc4dff3259974895
node scripts/docs-audit/affected-docs.mjs --json 9185ff0213c1590bf3808d5955cddd2becf924ea

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Aug 21, 2026
@claude

claudeBot commented Aug 21, 2026

Copy link
Copy Markdown
ContributorAuthor

PM review — verified against the tree and the ledger, not the report. Approving.

Step ① did what I asked and then went further: it overturned the census's reason

The dispatch said confirming or overturning the ruling was a successful outcome. You did both halves at once, which is the useful case:

  • The gap is real — the SORT axis produced 0 findings while the SEARCH axis produced 1 warning on the identical anchor, on the identical object, on one stack.
  • But the reason the ledger records for it is false. declaredFieldTarget returns non-null for an ADR-0015 external object that declares a mapped field map — the shipped showcase_ext_customer shape — so such an object does reach the union branch. The ledger's claim that skip ② catches it, leaving "no path on which the warning could fire", does not hold.

A card whose premise survives but whose stated reason does not is the case most likely to be waved through, because the headline still checks out. Both ledger rows now carry the measured reason instead.

And the runtime half is measured, not inferred: on a real SqlDriver over better-sqlite3 against a remote table lacking the anchors, ORDER BY created_at (and owner_id) returns asc and desc byte-identical, 3 rows, no error — while ORDER BY name reverses correctly. Neither runtime door refuses it, because both judge 'formula' alone.

⭐⭐ The contaminated measurement you caught on yourself

This is the sharpest instrumentation catch I have seen tonight:

"the first runtime run was contaminated — seeding the remote fixture through the managed driver's initObjects made its DDL inject created_at/updated_at into the fixture table, so the remote under test carried the very column the measurement was about"

The fixture setup manufactured the condition whose absence was the subject of the measurement. And the symptom was not a failure — asc/desc came back different rather than identical, which is a perfectly plausible-looking result that would have quietly understated the defect. You rebuilt with raw DDL so the remote carries exactly the five columns the showcase declares, and every reported number is from the corrected run.

Reporting that instead of silently re-running is what makes the rest of the numbers worth reading.

⭐ The ratchet a green typecheck could not see — I verified both halves

"packages/lint's tsconfig excludes **/*.test.ts, so pnpm typecheck is green over code that check:type-check-coverage --re-measure still measures."

Confirmed on origin/main: packages/lint/tsconfig.json has exclude: ['node_modules', 'dist', '**/*.spec.ts', '**/*.test.ts'], and TEST_DEBT['@objectstack/lint'] is errors: 19 with a note itemising per-test-file counts (validate-semantic-roles.test.ts ×5, …).

So pnpm --filter @objectstack/lint typecheck exiting 0 says nothing about the 198 lines of new tests. Reproducing the ratchet's own test-inclusive program caught your first version moving 19 → 20 (one TS2352 from a tuple cast), fixed at f29ecbfd4b and re-measured at exactly 19, with neither edited test file contributing an error.

That is a red CI run avoided by distrusting a green that was structurally incapable of covering the change.

The ablation predicts what should not move

Removing only the provenance findings.push reddened exactly the 6 predicted cases, named individually. The parts I want to highlight are the two non-movements you called in advance:

  • the census test stayed green, because a push-only ablation leaves the provenance-API tokens in the file — which is what that ledger judges by;
  • the four negative-direction cases stayed green, because they assert absence.

Predicting which assertions must hold still is harder than predicting which break, and it is what distinguishes an ablation from a demolition.

The rest

Ruling 2 upheld on fresh evidence rather than inheritance — the union is read at exactly one site, the key derives from registered metadata into which the registry injects the anchor on a federated object too, so the label resolves and asking provenance would flag a translation that works. Re-deriving a ruling you were inclined to keep is the right instinct.

The changeset judgement is correct.@objectstack/lint publishes and this adds a new warning rule id, so a real changeset — not skip-changeset. Same call as #10697 took, for the same reason.

The rebuild statement is stated rather than skipped: no dist boundary is crossed (both legs are same-package relative imports resolving to src), and the cross-package closure was built anyway because the rule reaches @objectstack/spec/data through its dist. "No rebuild leg applies, and here is why" is more useful than silence.

#10744 is well-filed and correctly routed to domain:engine — it belongs to the runtime doors (assertSortFieldsExist#6994, assertOrderByIsMaterializable#7095), not this seat's tree. And it names the real risk precisely: the new lint rule's justification can go stale with nothing turning red. Carrying the #7738 precedent for pinning a premise rather than assuming it is exactly right.

The context you didn't file separately — that examples/app-showcase declares two federated objects and no list view, view record or page binds either, so the federated read path has no authored SORT or SEARCH surface anywhere in the examples — is the explanation for why the #8999 census could only ever record this as a code-path reading. Folding it into #10744's context rather than minting a second card was right.

Arming.


Generated by Claude Code

@os-zhuang
os-zhuang marked this pull request as ready for review August 21, 2026 10:22
@os-zhuang
os-zhuang enabled auto-merge August 21, 2026 10:23
@os-zhuang
os-zhuang added this pull request to the merge queueAug 21, 2026
Merged via the queue into main with commit e2bb237Aug 21, 2026
32 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-10474-sort-axis-provenance branch August 21, 2026 10:42
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

the SORT axis reads the blanket SYSTEM_FIELDS union but never asks the #8116 provenance question, while the SEARCH axis on the identical index does

2 participants

@os-zhuang@claude