fix(metadata-protocol): dotted-path SORT hint prescribes a stored field, not a formula (#6924) - #6996
Conversation
…ld, not a formula (#6924) `assertSortFieldsExist` refuses a dotted `orderBy` and then told the author how to fix it: "Denormalise the value onto '<object>' (a formula or rollup field that copies it into a real column) and sort by that." Following that lands the author back inside the exact silent degradation the refusal saved them from. Measured on a REAL SqlDriver (better-sqlite3) and on InMemoryDriver, with a `formula` field named directly in `orderBy` (non-dotted, so the gate lets it through): control orderBy title asc -> A B C D E a real column really sorts baseline no sort -> C A E B D insertion order orderBy <formula> asc -> C A E B D 200 insertion order orderBy <formula> desc -> C A E B D 200 direction-blind `SqlDriver.createColumn` returns early for `formula` (no column), sqlite answers "no such column", the #3821 backstop retries WITHOUT the sort, and the response is 200 with every row present in arbitrary order. `rollup`/`summary` is dropped for a DIFFERENT reason, and the measurement contradicts the reported diagnosis: a summary field does get a real, maintained column (orderBy <summary> desc -> E D C B A over values 5 4 3 2 1). It simply cannot do this job, since a rollup aggregates CHILD records and cannot carry a looked-up parent's column onto the queried object. This overturns #4256's recorded wording choice (closed `completed`), which explicitly picked the "formula or rollup" phrasing as its remedy. The docs callout at content/docs/protocol/objectql/query-syntax.mdx taught the same denormalization, so code and docs agreed with each other about something untrue; both move here. "Stored" is #6673's vocabulary for the identical correction on the search axis. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W6bLax4KMrSfnE1ydFU8Dw
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 1 package(s): 4 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
|
ACCEPT — PM step-7 review ( The dispatch's whole point was the unmeasured premise, and it was measured. This card reached And half the card's argument is wrong, which the dev reported instead of banking. The filing claimed Verified against GitHub:
The changeset question — resolved as A, and it is a lane-principle call, not a judgement call
A stands: leave both. A changeset is a record of what a PR actually shipped, not a live instruction; editing another change's record to tidy a release note trades an honest history for a cosmetic one. This lane already holds the adjacent rule — release notes are written centrally at release time, and a PR's only inputs to them are its own changeset — so reaching into #4256's changeset would be this seat making a release-compilation decision from the wrong end. This PR's changeset names #4256 and states that it supersedes its prescription, which gives the release compiler the link it needs. If the release-notes author would rather not print the retracted advice, that is a one-line edit they can make at compile time with full context, which is where the judgement belongs. Out-of-scope finding, correctly not absorbed#6994 filed: a non-dotted Generated by Claude Code |
Fixes #6924
1. The repro, first — the premise HOLDS for
formula, and is FALSIFIED forrollupThis card was dispatched verification-first: it was promoted to
pm:queueon "same defectclass as #6673", but the promotion gate the first triage comment set — one measured repro —
had never been taken. The card is honest that its core claim is inferred from architecture,
not observed. So the measurement came first.
Method. A real
SqlDriver(better-sqlite3, on-disk) and a realInMemoryDriverwired toa real
ObjectQLengine, plusObjectStackProtocolImplementationon top for the ingresshalf. An object carrying a
formulafield (sort_key, expressionrecord.title) and asummaryfield (child_total,count). Five rows insertedC A E B Dso that "sorted" and"insertion order" are distinguishable byte for byte. The formula field is named directly
in
orderBy— non-dotted, soassertSortFieldsExistlets it through and the driver reallysees it.
Predictions were written down before the run (P1 formula degrades silently; P2 summary
has a real column and sorts; P3 memory driver same as SQL; P4 the ingress gate does not
refuse it).
Result — driver-sql (better-sqlite3):
Result — driver-memory:
P1, P3 and P4 confirmed. The premise holds for
formula. Sorting by aformulafieldreturns
200, every row present, in insertion order, identically forascanddesc—which is what makes it a dropped sort rather than a coincidence.
SqlDriver.createColumnreturns early for
formula(case 'formula': return; // Virtual — no column), the engineevaluates the expression only after the driver returns (
applyFormulaPlan), sqlite rejectsthe
ORDER BY, and the #3821 unknown-column backstop retries without the sort. The responseeven carries the
sort_keyvalues in plain view —C, A, E, B, D— in a column the callerasked to be sorted ascending.
The ingress gate does not refuse it either, so the hint pointed the author at a shape that
lands them back inside the exact silent degradation #4226/#4256 exist to stop, one step
after being refused for it.
But the card's
rollup/summarydiagnosis is FALSIFIED. The card assertedsummaryis"likewise excluded from real-column treatment". Measured, it is not:
A
summaryfield gets a real, maintained column andORDER BYon it genuinely works.rollupis still dropped from the hint, but for a different and weaker reason: it aggregateschild records (
count/sum/min/max/avg), so it cannot carry a looked-up parent'scolumn such as
account.company_nameonto the queried object. Wrong tool, not a broken one.That distinction is recorded in the code comment rather than smoothed over, because
"unmaterializable" would have been a false claim about
summary.2. The fix
assertSortFieldsExist's hint, before:after:
(
OBJECTstands in for the interpolated object name — GitHub's body sanitizer eats anangle-bracket placeholder, so it is spelled out here.)
Prescription first, trap second. "Stored" is #6673's vocabulary, deliberately reused
rather than re-invented: that PR landed "copy the value onto a stored text field" /
"mirror it onto a stored text field" for the identical correction on the SEARCH axis. The two
axes now say the same word. The trap clause is carried here — and not in #6673 — because the
measured failure is silent, and because the docs on this axis were still teaching the wrong
answer until this PR (#6673's docs corpus had already moved to "stored").
3. This overturns a recorded decision
#4256 (closed
completed, same filer) explicitly proposed and got this exact wording asits chosen remedy for dotted-path sort. This is not a leftover sweep. The reason to overturn
it is narrow and measured: the remedy prescribes something the platform cannot materialize,
so a refusal whose whole purpose is to stop a silent degradation was routing the author
straight back into it. The rationale is written into
assertSortFieldsExist's doc comment,with the measurement, so the next reader finds the overturn where the decision lives.
#4256's own changeset (.changeset/sort-dotted-path-rejected.md, still pending — the repohas ~1550 unreleased changesets) also describes the old prescription. It is deliberately
left untouched: it is an accurate record of what that PR shipped. This PR's changeset names
it and states that it supersedes its prescription, so the release compiler has the link. Flagging
it for the PM rather than editing another change's record.
4. File surface — exactly as dispatched, no deviations
packages/metadata-protocol/src/protocol.tsassertSortFieldsExisthint text + the doc comment recording the overturn and the measurement. Nothing else in the file — thesaveMetaItemregion (#6190, ~:7xxx) is untouched.packages/objectql/src/query-expression-conformance.test.tspackages/objectql.content/docs/protocol/objectql/query-syntax.mdxformulatrap and therollupdistinction with the measured numbers..changeset/sort-hint-prescribes-stored-field.mdcontent/docs/releases/untouched.packages/specuntouched.5. Reverse verification — direction predicted before running
Prediction (written before the fix existed): reverting the
protocol.tshint to the oldwording turns the new pinned assertion RED, and only that one — the fix is a string, the pin
is of that string, so this is the plain before-green/after-red direction with no inversion.
Result — as predicted. Fix taken out with a patch file (
git diff > …patch+git checkout --; nevergit stash, which shares one stack across every worktree),metadata-protocol rebuilt, suite re-run:
1 red, 90 green, and the failure prints the old hint verbatim. Fix restored via
git apply;suite back to 91/91. No prediction missed.
The pin was also strengthened while moving, per the rejection-envelope rule: it now asserts
status: 400andcode: 'INVALID_SORT'alongside the wording, instead of the previousbare
.rejects.toThrow(/…/). Note it can not assertnot.toContain('formula')the way#6673's pins do — the new text names
formulaas the trap. It asserts the stronger pairinstead:
/a stored field/must be present,/formula or rollup/must be gone, and/Not a formula field/must be present, so a reword that keeps the dead end in a subordinateclause still fails.
6. Verification
pnpm --filter @objectstack/metadata-protocol test→ 65 files, 813 tests passedpnpm --filter @objectstack/objectql test→ 161 files, 2770 tests passedpnpm --filter @objectstack/objectql typecheck→tsc --noEmitclean(
metadata-protocoldeclares notypecheckscript — it carries a ledger entry)eslint --no-inline-configon both changed source files → exit 0check:doc-authoring,check:docs-audit-scope,check:empty-changeset,check:error-code-casing,check:route-envelope,check:engine-double-contract,check:adr-linksnode scripts/check-nul-bytes.mjs→ OK (6467 files); plus a targeted control-byteself-scan of the four changed files → no matches
7. Out of scope, filed separately — NOT absorbed here
Filed as #6994 (unassigned, for PM triage; routing suggestion
domain:engine-core).The repro surfaced a genuine engine-side defect that is not hint text: a non-dotted
orderBynaming aformulafield is accepted byassertSortFieldsExist(a formula field isin
gate.known), reaches the driver, and answers200in arbitrary order — the same silentdegradation, on a shape no gate covers. The full measurement above is repeated on that issue,
along with the three candidate remedies (refuse at ingress / refuse at the driver /
materialize) and the wrinkle that an ingress refusal must name the same "stored field" remedy
this PR just landed, or the two doors disagree again.
Whether to refuse, and where, is a
domain:engine-coredecision — so it is filed rather thandecided here.
Generated by Claude Code