fix(service-analytics): resolve a user (and tree) dimension's display label through its reference, as lookup already does - #17470
Conversation
… through spec's arbiter Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ToDPcx9AESFubJkDiFMtKW
Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ToDPcx9AESFubJkDiFMtKW
…er-dimension-display-name
Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ToDPcx9AESFubJkDiFMtKW
📓 Docs Drift CheckThis PR changes 1 package(s): 21 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 6 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 9 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 46332726694e99851d1e794aed90ee80f4ab3cf1 && git checkout 46332726694e99851d1e794aed90ee80f4ab3cf1
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin c4d1759fa3c73b68cd16df8bf8562c4f86268dc6 44918e39daec2d24b6390cc52299303065a93088 && git checkout -B drift-repro c4d1759fa3c73b68cd16df8bf8562c4f86268dc6 && git merge --no-ff 44918e39daec2d24b6390cc52299303065a93088
node scripts/docs-audit/affected-docs.mjs --json c4d1759fa3c73b68cd16df8bf8562c4f86268dc6
|
Fixes #16390
Clause-②: no — no symbol is published, no error code is minted, and no authorable key is added.
DatasetDimensionSchema(and all ofpackages/spec) is read, never written. What changes is a RETURNED VALUE: auserdimension's cell was the stored user id and is now the referenced user's display name. Re-tested against the actual diff after writing it:git diffoverpackages/services/service-analytics/srcadds no line containingexport.What this changes
packages/specdeclares one reference class —REFERENCE_VALUE_TYPES=lookup,master_detail,user,tree, "value points at another record … a record-id string in stored form" — andservice-analyticsalready treats it as one class where it annotates measure result types (measure-result-type.tsimports that very set). The label resolver, one file away, hand-wrote a two-member subset of it, so a single dataset query answered one axis with a name and the other with a raw id for two fields that differ in one word:packages/services/service-analytics/src/dimension-labels.tsnow asks spec'sreferenceTargetOf— the declared SINGLE arbiter of "what does this reference field point at" — at all three sites that classified a dimension: the display pass inresolveDimensionLabels, plus both classification points of the#3680order-label hook (isLabelBearing, and the label fetch beside it). The privateLOOKUP_TYPESset is gone rather than extended by two literals, because a hand-written subset of a declared class is the defect, not the two members it happened to be missing.Two consequences that a
LOOKUP_TYPES.add('user')patch would not have had:userfield authored withoutreferenceresolves too.sys_useris a constant of the type, whichreferenceTargetOfmaterializes. That shape reaches production —packages/objectql/src/query-expression-conformance.test.tscaptures one — and the oldmeta.reference &&test dropped it silently.#3602read scope reaches the new members. Turning a user id into a name IS a read ofsys_user. It travels the sameLabelScopeResolverpath every other member travels: the referenced object's own RLS is resolved and ANDed into the id lookup, and an unresolvable scope still fails closed to the raw id rather than fetching unscoped. This had to land WITH the widening, not after it — a resolution that readsys_userunscoped would leak "who is who" to a caller who cannot readsys_user.Route: (a), as ruled.
packages/specis untouchedRoute (b) — a
displayFieldkey on the strictDatasetDimensionSchema— is not taken and was not needed: nothing about (a) required a spec type to widen, so the prohibition never fired. Nor is a name denormalised into the result table.DatasetDimensionSchema.typeis['string','number','date','boolean','lookup']— it has nousermember at all — which is the structural reason the fix belongs at the resolution site: there is no author-facing declaration to fix it with.Zone-3 re-verification — the card's repro is STILL LIVE on today's
mainThe card was filed 2026-09-06 and #16778 landed in this package today, so the premises were re-measured rather than carried across, at base
9788f1e91:dimension-labels.tsstill holds it, and its last touch is#17061, before this card.Field.user()still returns{ type: 'user', reference: 'sys_user' }andField.lookup(...)still returns{ type: 'lookup', reference: … }— asserted by the new pin itself, on every run, from the real builders.type: 'lookup'still does not change the result: outside thedatebranch the resolver reads only the OBJECT field's type. Pinned both ways (a user axis declaredlookupand one declaredstringresolve identically).main: 7 failed / 5 passed. The defect reproduces.Verification
Base
fd62a66b7, reported at HEAD44918e39d.Tests —
pnpm --filter @objectstack/service-analytics test: 106 files / 2284 tests passed.typecheck: clean, andtsc --noEmit --listFiles | grep -cconfirms the new test file is one of the files that program compiles (1), so the typecheck really covers it.Ablation (C5). The fix is committed first; the mutation restores the pre-fix classification verbatim inside
referenceLabelTarget(the two-member subset plus themeta.referencetest). On-disk proof, not an editor exit code — the deleted text goes 1 to 0 and the injected marker 0 to 1, and the blob hash moves off the HEAD blob. Resolution path: the pin imports the subject relatively inside its own package, so it reads source, notdist(pnpm check:test-source-aliaspasses, and the spec dependency it does read fromdistis unchanged by the mutation).Direction was predicted before running and is written into the test header: ordinary, no inversion, no count movement — red exactly where a
userortreeaxis is load-bearing, green for everylookup,master_detail,select, unresolved-id, no-display-field and fail-closed case.master_detailwas already inside the old subset, so it is a control here, not a casualty. Predicted 7 red in the new file + 3 in the sibling; measured exactly that. The 7 also match the pre-fix run above, so the ablation reproduces the original defect rather than some third state.Restoration proven BY STATE, not by exit code:
Gates. Derived from the actual diff (
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack), all 60 run, exit codes captured before any pipe, reconciled with--ran: 60 derived, 57 run green, 3 NOT MEASURED, 0 unrun. The three arecheck:dual-build-cjs-loads,check:lean-entry-closureandcheck:type-check-debt— each exits 3, PREREQUISITE NOT MET, because each reads built output for the whole workspace. Those are CI's repo-scale build, not a per-card local run; they are recorded as NOT MEASURED, not as passes. One caveat on the derivation itself: it was taken at44918e39d, by which timeorigin/mainhad moved one derivation-source script (scripts/measure-test-shard-timings.mjs); the family it feeds (report-test-timings.mjs --self-test) is in the list above and ran green.Lint — a declared narrowing, with its three pieces of evidence.
pnpm lint(=eslint . --no-inline-configover the repo root) is CI's run. Locally this diff was linted file-by-file at HEAD44918e39d:--format jsonreports 3 files, 0 errors, 0 warnings (the changeset markdown is not a lint target — eslint answers "File ignored because no matching configuration was supplied" for it, so 3 is the diff's whole lint surface). The narrowing excludes nothing, because this repo runs oneeslint.config.mjswhich never enables type-aware linting for ANY file — noparserOptions.project, no typed@typescript-eslintrules, stated and positively controlled in the config's own header aboveQUERY_OPTIONS_TEST_GLOBS. With no cross-file type program, an edit confined to three files cannot move the verdict on a file it did not touch.Acceptance notes
C3 — the symmetry pin.
packages/services/service-analytics/src/__tests__/dataset-reference-dimension-labels.test.tsruns ONEAnalyticsService.queryDatasetover one dataset holding aField.lookup()axis and aField.user()axis with anavgmeasure, and asserts a display name in BOTH cells of the same row.queryDatasetis exactly whatPOST /api/v1/analytics/dataset/querydispatches to (rest-server.tscallssvc.queryDatasetand does nothing to the rows); the route itself lives inpackages/rest, outside this card's declared file surface, so it is not re-tested here.C4 — the negative control, stated. An unresolvable user degrades to the raw id and the query still answers. Three shapes are pinned: an orphaned or RLS-hidden user id (the row keeps the id; a sibling row in the same result still resolves), a
sys_usercarrying no display field at all (every id stays raw), and asys_userunknown to the engine (same). A fourth, the fail-closed one, is pinned separately: when the read-scope provider throws forsys_user, nothing is fetched and the id renders. Nothing throws, nothing blanks, and no id-labelled chart turns into an error — this is the pre-existing contract for an unresolved lookup id, now reached by the same code path.Triage's stated acceptance condition, and one correction to it. The triage comment asked that
dimension-labels.test.ts:167'sexpect(scopeCalls).toBe(0)be converted into a positive four-member assertion. Read on the tree, that assertion is about a select dimension, which resolves from field metadata and reads no other object — it stays correct after the widening and was kept, with its now-stale trailing comment rewritten. The positive counterpart it asked for was ADDED beside it instead:#16390 — a user and a tree dimension resolve the REFERENCED object scope, same as lookupasserts the resolver is consulted forcrm_account,sys_userandtaskin order, and that each scope reachesfetchRecordLabels.Out of scope, noted, not filed. Two further two-member spellings of the same class survive in this package, both outside this card's declared file surface:
plugin.ts—relationshipResolveradmits onlylookup/master_detailwhen mapping a datasetincludeto a joined table. Reachable throughDataset.include, not through the dimension path this card fixes, and auserjoin has a fixed target so it is a different question than the label read. Carrier: the next PR that touches dataset joins inservice-analytics.dataset-executor.ts(order-hook prose) andanalytics-service.ts(a doc comment) describe the sort behaviour asselectorlookup/master_detail. The BEHAVIOUR is already corrected by this PR — both readcreateOrderLabelResolver, which now covers the class, so a person axis sorts by name from this commit on. Only the two comments are now understated. Carrier: the same next PR; comment-only drift, filed nowhere.Neither is a filed card: both are stale prose or an untested adjacent path, i.e. observations, not reproducible defects, contract breaches, or metadata traps.
Docs drift check — answered
Re-derived on the tree the bot actually read, not a worktree cut from an older
main: merge commit46332726694e99851d1e794aed90ee80f4ab3cf1(head44918e39dainto basec4d1759fa3), fetched and checked out per the comment's own recipe, thennode scripts/docs-audit/affected-docs.mjs --json c4d1759fa3c73b68cd16df8bf8562c4f86268dc6. Same numbers as the advisory: 27 docs (21 hand-written + 6 release-owned), 7 anchors, 9 package-mention fallback rows.Why all 21 rows arrived, in one sentence. Every one of the 27 rows is anchored on the same single item —
master_detail (literal, a string literal in LOOKUP_TYPES). This diff DELETES theLOOKUP_TYPESset, whose members were the literals'lookup'and'master_detail', so every page that spells the field typemaster_detailanywhere was selected. That is the anchor doing its job precisely; it is not a wrong row. But it also means the listing carries no signal about which page states the RULE this diff changed, so each page was read against the change rather than dismissed as a class.Per-page verdict — the 21 hand-written pages. One is falsified. The other 20 are not, and they divide into three groups by what their
master_detailoccurrence actually says:content/docs/data-modeling/analytics.mdx:209-213. The falsifying sentence: "aselectdimension returns its option label (not the stored value), alookup/master_detaildimension returns the related record's display name (not the FK id), and adatedimension … returns a human bucket label". This is the page that restates exactly the rule this PR widened. After this change auserand atreedimension also return the related record's display name, so the two-member enumeration is now wrong by omission — and wrong in the direction that matters, because a reader asking "does a person axis render a name?" reads this list and concludes it does not. Reported below, ⛔ not edited.data-modeling/field-types.mdx,field-type-decision-tree.mdx,fields.mdx,relationships.mdx,index.mdx,validation-rules.mdx,getting-started/common-patterns.mdx,quick-reference.mdx,deployment/cli.mdx,deployment/troubleshooting.mdx,protocol/objectql/types.mdx. Each namesmaster_detailas a field type — cascade delete, parent-child modelling,Field.masterDetail(), delete behaviour. None makes a claim about analytics, dimensions or what a grouped value renders as. This diff changes no field type and no delete behaviour.api/data-api.mdx:24,automation/hook-bodies.mdx:103,data-modeling/queries.mdx:328,protocol/objectql/query-syntax.mdx:739and:803,protocol/objectui/widget-contract.mdx:162. These describeexpand/ the relational field family and already enumeratelookup/master_detail/user/tree(two of them namingREFERENCE_VALUE_TYPESoutright). They agree with this change rather than being falsified by it — they document the same class this diff stops splitting.api/error-catalog.mdx,protocol/kernel/error-handling.mdx,permissions/authorization.mdx,protocol/objectql/schema.mdx. The first three describe the master-access gate and required-field validation for amaster_detail; this diff mints no error code, changes no status and touches no admission path. On the fourth,schema.mdx:298(| reference | string | lookup, master_detail | Target object for relationships. |) understates which types carryreference— but it understated it identically before this diff, sincereferenceTargetOfandREFERENCE_VALUE_TYPESpredate it. Pre-existing, not caused here, and reported as an observation rather than a finding.⭐ The half the bot states it can never do — hand-checked emitter-blind set. The advisory says a page that states a rule by its INPUTS shares no identifier with the emitter, so an emitter-only diff cannot list it on this or any run. So the analytics / dataset / dimension / chart / report / dashboard surface was swept by hand for pages that describe what a dimension axis renders WITHOUT naming
referenceTargetOf,REFERENCE_VALUE_TYPES,dimension-labels,createOrderLabelResolverormaster_detail. Two sweeps over all ofcontent/docsoutsidereleases/: one on output phrasings (display name,display label,FK id,raw id,option label,stored value,presentation-ready,resolved server-side), one on rendering verbs neardimension/axis(shows the,renders,returns the,reads the,id instead,name instead). It found a second falsified page the bot did not and could not list:content/docs/ui/dashboards.mdx:146-150. The falsifying sentence: "AsortBynaming a select or lookup dimension orders by the display label the rows render (the option label / the related record's name), not the stored value or foreign-key id". This states the#3680sort-key rule, which this diff widened throughcreateOrderLabelResolver.isLabelBearing: asortBynaming auserortreedimension now orders by the display label too. The page names none of this change's identifiers — notmaster_detail, notOrderLabelResolver— which is exactly why no anchor could reach it. Reported below, ⛔ not edited. (Its omission ofmaster_detailis separately pre-existing and not caused here.)Also read and cleared in that sweep, so the next reader need not redo them:
references/ui/dataset.mdx:57andreferences/data/analytics.mdx:129(generated reference tables mirroringDatasetDimensionSchema.type/ the Cube dimension shape —packages/specis untouched, so neither moved);references/ui/report.mdx,references/ui/chart.mdx,references/api/analytics.mdx,references/api/sortability.mdx,capabilities/analytics.mdx(name dimensions but state nothing about the rendered value); andui/reports.mdx:157-159, which explicitly delegates the ordering rule todata-modeling/analytics.mdxand says it "does not repeat" it — so it carries no independent claim to falsify.The 6 release-owned pages: read, and left alone.
releases/implementation-status.mdx,releases/v12.mdx,releases/v17/17-0.mdx,releases/v17/17-3.mdx,releases/v17/17-4.mdx,releases/v9.mdx. ⛔ Nothing undercontent/docs/releases/**was edited. Two of them do state this rule —v9.mdx:114-115("aselectdimension shows its option label … alookup/master_detaildimension shows the …") and17-0.mdx:1385("selections sort by display label for select/lookup dimensions") — and neither is falsified, for a reason rather than by exemption: a release page is a dated record of what a given release shipped, and both sentences remain true of v9 and 17.0 respectively. A later widening does not retro-falsify the note that describes the narrower behaviour at its own release. If that reading is wrong, it is a report, not an edit.Two findings handed to the
domain:servicesseat, ⛔ not edited here.content/docs/**isdomain:devx, so neither page is touched in this PR:content/docs/data-modeling/analytics.mdx:211-212— "alookup/master_detaildimension returns the related record's display name (not the FK id)" should name all four members of the reference class.content/docs/ui/dashboards.mdx:146-147— "AsortBynaming a select or lookup dimension orders by the display label" should name the reference class, notlookupalone.Neither is a generated block, so the generated-surface exception does not apply.
Generated by Claude Code
Generated by Claude Code