Skip to content

fix(service-analytics): a picklist-bound select dimension serves its category labels in the request locale - #22213

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-22178-analytics-picklist-labels
Oct 8, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-22178-analytics-picklist-labels

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #22178

Clause-②: no

A select dimension bound to a shared list (picklist: 'NAME') now serves its chart and report category labels in the request's locale on POST /api/v1/analytics/dataset/query. Before this change it served the authored (English) labels under every Accept-Language.

What changed

  • packages/services/service-analytics/src/plugin.ts, the translateSelectOptions bridge. The synthetic field handed to translateObject now carries the dimension field's picklist. The value is read from the same served field the caller's options came from (getObject(objectName).fields). The resolver looks up picklists.NAME.options.VALUE only for a field that carries picklist, and the synthetic field used to carry options alone. A field with inline options has no picklist, so its synthetic field is built exactly as before.
  • packages/services/service-analytics/src/dimension-labels.ts, createOrderLabelResolver.resolveLabels. For a select dimension, the sort-key pass now reads the options translateSelectOptions returns for the request locale, the same options the display pass reads. The DimensionLabelDeps.translateSelectOptions docblock now names the picklist address and both passes. The "Scope" section below explains why this file is in the PR.
  • One @objectstack/service-analytics patch changeset.

Rulings executed (triage comment 6052688863, verbatim; its one angle-bracket placeholder respelled NAME)

  • build the synthetic field with the dimension's picklist, so picklists.NAME translations apply
  • inline options behave as today
  • Pins: Accept-Language: zh-CN and ja-JP serve translated labels for a picklist-bound dimension; control: inline options are unchanged.
  • Clause-②: no. Patch changeset for the analytics service.

Mechanism hypotheses, measured

  • H1: confirmed on 6ed0c0f3. plugin.ts:1071-1073 built a synthetic field holding only name and options. packages/spec/src/system/i18n-resolver.ts:3063-3068 calls lookupPicklistOption only when typeof def.picklist === 'string'.
  • H2: confirmed. The engine's getObject serves the picklist-bound field with picklist next to its resolved options (resolvePicklistFieldsOnto, the served shape PicklistServedFieldSchema declares). The bridge reads picklist there, and the caller's signature is unchanged. The first test pins this precondition against a real ObjectQL engine.
  • H3: confirmed. The picklist branch runs whenever picklist is set. The resolver's own precedence still applies: a field-level entry wins, then the list's entry. A zh-CN field-level entry for logistics pins it. A field with only inline options gets the same synthetic field as before.
  • H4: pinned at the service call the door makes. The door lives in @objectstack/rest, which this package does not depend on, so the pins call AnalyticsService.queryDataset (the call POST /api/v1/analytics/dataset/query makes with the request's ExecutionContext). They run through the plugin's own composition, with a real ObjectQL engine on SQLite as data and an i18n service as i18n, on both strategies (native SQL and ObjectQL aggregate).

Labels per locale

"Before" is main's behaviour, measured by ablation A1 below. "After" is this branch. Fixture: industry is bound to picklist os22178_industry; type has inline options.

locale dimension before after
zh-CN industry (picklist) Education · Energy & Utilities · 物流运输 教育 · 能源公用事业 · 物流运输
ja-JP industry (picklist) Education · Energy & Utilities · Logistics 教育・学術 · エネルギー・公益 · ロジスティクス
zh-CN type (inline, control) 正式客户 · 合作伙伴 正式客户 · 合作伙伴
ja-JP type (inline, control) 顧客 · パートナー 顧客 · パートナー
none both (control) authored authored

物流运输 in zh-CN comes from the field-level entry: it applied before, and it still beats the list's 物流 after. For each locale, every industry label equals what translateObject returns for the served object (the object-metadata door's answer for the same field).

Ascending order (with limit: 1 keeping the first row):

locale dimension before (authored-label order) after (rendered-label order)
zh-CN industry 教育 · 能源公用事业 · 物流运输 教育 · 物流运输 · 能源公用事业
zh-CN type 正式客户 · 合作伙伴 合作伙伴 · 正式客户
ja-JP industry 教育・学術 · エネルギー・公益 · ロジスティクス エネルギー・公益 · ロジスティクス · 教育・学術
ja-JP type 顧客 · パートナー パートナー · 顧客
none both (control) authored order authored order

Scope: one step past the dispatch fence, and why

The dispatch fenced this card to the translateSelectOptions region of plugin.ts. Once the picklist-bound dimension's display labels were translated, the sort-key pass (createOrderLabelResolver) no longer matched the display for that dimension. An order sorted, and a limit windowed, by the English label while the row showed the translated one. Before the change both were English, so they agreed. Inline-options dimensions already had this gap, because the sort-key pass never read the locale.

The dev agent definition requires fixing a shipped defect that the change makes false, and that rule outranks a dispatch fence. So the fix is here, with its own pins and ablation (A2). It is two lines that mirror the display pass. No open PR touches packages/services/service-analytics (the file lists of all 11 open PRs were read).

The claim's file surface should add packages/services/service-analytics/src/dimension-labels.ts (createOrderLabelResolver.resolveLabels, the DimensionLabelDeps.translateSelectOptions docblock). Not touched: packages/spec, and the admitObjectRead region of plugin.ts that #22154 holds next.

Tests (at 59ccbaf04d)

  • New: src/__tests__/picklist-dimension-label-i18n.test.ts, 26 passed (26).
  • pnpm --filter @objectstack/service-analytics exec vitest run --maxWorkers=2: Test Files 179 passed (179), Tests 4436 passed, 262 skipped (4698).
  • pnpm --filter @objectstack/service-analytics typecheck (tsc --noEmit): exit 0. --listFiles includes the new test file (176 test files in the program).
  • Dependency closure pnpm --filter '@objectstack/service-analytics^...' build: exit 0. @objectstack/service-analytics build: exit 0, check-dts-emitted 2/2.
  • Narrowed lint: eslint --no-inline-config --format json over the 3 changed .ts files. The JSON reports 3 files, 0 errors, 0 warnings, and none is ignored by eslint.config.mjs. The config enables no type-aware linting (no parserOptions.project), so this diff cannot change the verdict on any file it does not touch. The full pnpm lint run is left to CI.

Ablations

Each ablation used scripts/ablation-replace.mjs in wrap mode under the verify lock: the anchor must hit, the on-disk blob change is verified, and the file is restored on exit, INT or TERM. The subject is imported relatively (../plugin.js, ../dimension-labels.js) and resolves to src/, so neither leg goes through dist/.

  • A1: drop the picklist spread in plugin.ts. Anchor 1 to 0, blob b272fe903c6e to fa6bf8178e43. Result: 12 failed, 14 passed. The 8 picklist display pins and the 4 picklist sort pins failed. The inline controls, the no-locale controls and the precondition stayed green. Restored: blob equals HEAD (b272fe903c6e), and git diff HEAD is empty.
  • A2: sort-key pass reads meta.options again (dimension-labels.ts). Anchor 1 to 0, blob 228491a908f1 to 7050404c0de1. Result: 8 failed, 18 passed. Exactly the 8 sort pins failed, each receiving the authored-label order (for example 正式客户, 合作伙伴 where 合作伙伴, 正式客户 was expected). Restored: blob equals HEAD (228491a908f1), and git diff HEAD is empty.
  • A fixture guard pins that the rendered-label order is the order localeCompare (the executor's comparator) produces, and that it differs from the authored-label order for every locale and dimension. So the sort pins cannot pass vacuously.

Gates (at 59ccbaf04d)

node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 64 commands from the merge base 6ed0c0f3e (4 paths). All 64 exited 0. Reconciliation with --ran reported: "64 derived famil(ies) accounted for — 64 run, 0 NOT-MEASURED".

The derivation names no i18n family. As extra evidence, check:i18n, check:i18n-walk-parity, check:i18n-stale-fill and check:i18n-coverage were also run, and each exited 0 with an OK line.

The derivation itself lists what it leaves outside the 64 commands and does not measure: the CI jobs scheduled by these paths (Test Core, Temporal Conformance, Dogfood, Build Core), the CI type-check lanes, and the artifact-roster families. CI runs those.

Acceptance notes

  • The HTTP door itself was not booted in-package. The door hands its ExecutionContext to queryDataset unchanged, so the pins stand at that call.
  • The executor compares sort labels with localeCompare() and passes no locale, so rendered labels collate in the process's root order, not the request locale's. For example, zh-CN pinyin order would be 教育 · 能源公用事业 · 物流运输, and root order is 教育 · 物流运输 · 能源公用事业. No contract names locale-aware collation. This is noted, not filed.

Generated by Claude Code

claude added 4 commits October 8, 2026 05:27
…ry labels

The analytics label bridge built its synthetic field from `options` alone,
so `translateObject` never took its `picklists.<name>.options.<value>`
branch and a picklist-bound select dimension rendered its authored labels in
every locale. The synthetic field now carries the served field's `picklist`;
a field with inline `options` is built exactly as before.

Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ
Co-authored-by: Claude <noreply@anthropic.com>
…rs in the request locale

The sort-key pass read the authored option labels while the display pass
rendered translated ones, so an `order` on a select dimension, and the
`limit` window behind it, followed text the user never sees. Translating the
picklist-bound dimension's display labels made that true for it too. The
sort-key pass now reads the options `translateSelectOptions` answers for the
request locale, exactly as the display pass does.

Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added the size/m label Oct 8, 2026
@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface labels Oct 8, 2026
@github-actions

github-actions Bot commented Oct 8, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/service-analytics, touching 4 documentable anchor(s).

7 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/wire-format.mdx (via /meta/object/:name (route, a path literal in a comment in AnalyticsServicePlugin))
  • content/docs/concepts/metadata-lifecycle.mdx (via /meta/object/:name (route, a path literal in a comment in AnalyticsServicePlugin))
  • content/docs/deployment/production-readiness.mdx (via /meta/object/:name (route, a path literal in a comment in AnalyticsServicePlugin))
  • content/docs/plugins/packages.mdx (via AnalyticsServicePlugin (symbol, a top-level class))
  • content/docs/protocol/kernel/http-protocol.mdx (via /meta/object/:name (route, a path literal in a comment in AnalyticsServicePlugin))
  • content/docs/protocol/objectql/state-machine.mdx (via /meta/object/:name (route, a path literal in a comment in AnalyticsServicePlugin))
  • content/docs/ui/forms.mdx (via /meta/object/:name (route, a path literal in a comment in AnalyticsServicePlugin))

⛔ 4 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17/17-0.mdx (via /meta/object/:name (route, a path literal in a comment in AnalyticsServicePlugin))
  • content/docs/releases/v17/17-1.mdx (via /meta/object/:name (route, a path literal in a comment in AnalyticsServicePlugin))
  • content/docs/releases/v17/17-6.mdx (via AnalyticsServicePlugin (symbol, a top-level class))
  • content/docs/releases/v17/17-7.mdx (via /meta/object/:name (route, a path literal in a comment in AnalyticsServicePlugin))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • 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.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 10 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 7d7943dd0dfba6c98afa2200a08983ec85f9849a → packageMentionDocs.

Which tree this was computed on

This run read content/docs from ec6d07044e57c860245e12535efa5f61edbaaed3 — the merge of head 59ccbaf04dabe16ad6a54f9f77e7297f5024ed33 into base 7d7943dd0dfba6c98afa2200a08983ec85f9849a, 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 ec6d07044e57c860245e12535efa5f61edbaaed3 && git checkout ec6d07044e57c860245e12535efa5f61edbaaed3
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 7d7943dd0dfba6c98afa2200a08983ec85f9849a 59ccbaf04dabe16ad6a54f9f77e7297f5024ed33 && git checkout -B drift-repro 7d7943dd0dfba6c98afa2200a08983ec85f9849a && git merge --no-ff 59ccbaf04dabe16ad6a54f9f77e7297f5024ed33

node scripts/docs-audit/affected-docs.mjs --json 7d7943dd0dfba6c98afa2200a08983ec85f9849a

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

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 7d7943dd0dfba6c98afa2200a08983ec85f9849a → pass the list as
args.docs, on the commit named under Which tree this was computed on.

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 size/m tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface

Projects

None yet

2 participants