Skip to content

fix(plugin-approvals): serve the approval flow, step and object labels in the reader's locale - #24977

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-24863-approval-labels-reader-locale
Oct 20, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-24863-approval-labels-reader-locale

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #24863

Clause-②: no

This is the services half of #24863 (triage split R539): the request labels and the inbox row's object label. The approval.requested notification half is NOT in this PR. It needs a decision, set out in Open questions below, so the card stays open (Part of, not Fixes).

What changes

The approvals door now serves a request's flow, step and object labels in the reader's locale, picked when the row is read.

  • Doors. GET /api/v1/approvals/requests (the inbox and the record's approval panel), GET /api/v1/approvals/requests/:id, and the request a decision answers with.
  • Fields. process_label, step_label / current_step_label (two names of one value, which move together) and object_label. The get's step progress (flow_steps) names each step the same way, so the strip and current_step_label agree.
  • Keys. flows.FLOW.label, flows.FLOW.approvals.NODE.label (the key PR feat(spec,lint): flows translation face gains approvals.NODE.label for an approval node's step label #24943 added) and objects.OBJECT.label (objectLabelKey).
  • Fallback. The stored snapshot, whenever nothing is picked: a key the pack lacks, a locale no pack holds, a read with no locale, no i18n service, or a service that throws.
  • Unchanged.
    • The stored process_label / current_step_label columns. No column is added, rewritten or migrated.
    • The generic data door and the list views, which still serve the stored text.
    • Who may read a request.
    • The notification bodies.

Files

  • packages/plugins/plugin-approvals/src/reader-locale-labels.ts (new): the keys, the pick and the overlay.
  • approval-service.ts: the new option ApprovalServiceOptions.i18nServiceSource, and readerLabels(context). listRequests and loadRequest apply it after enrichRows, and attachFlowSteps takes the same labels.
  • approvals-plugin.ts: one lazy i18n reader feeds both messageTranslator (unchanged behaviour) and i18nServiceSource.
  • display-labels.ts and the open-time snapshot comment: the docblocks now say the chain answers the authored label and the door overlays the translation.
  • .changeset/24863-approval-labels-reader-locale.md: @objectstack/plugin-approvals patch.

Mechanism (measured; reused, not a second one)

Ruling 6093472708 on #22507 says a server-rendered user string is translated where it is rendered. I measured how this repo's server already renders a user-read string in the caller's language. The pattern is the same in three places:

  • objectql objectDisplayLabel (objectLabelKey);
  • packages/rest/src/not-null-refusal-label.ts;
  • the automation engine's renderFlowTextSlot.

All three take ExecutionContext.locale, negotiate it against the i18n service's getLocales() with resolveBundleLocale, call II18nService.t(key, locale) (a miss echoes the key), and fall back to the authored text. This PR uses the same sequence. The service is read lazily from the kernel (ctx.getService('i18n')), the same read the plugin's existing messageTranslator already makes.

The mechanism assumptions, measured on origin/main 693352e4c2

  • H1 holds. Every door served the stored row with no locale step.
    • rowFromRequest (approval-service.ts:1203, labels at :1233–:1239) builds the labels from requestDisplayLabels (the stored column, then the config snapshot, then the machine name).
    • It serves listRequests (:8109, rows at :8160) and loadRequest (:8274). Those feed getRequest (:8247) and readBackRequest (:8232, a decision's echo).
    • object_label was objectLabelOf (:7374), the schema label, set at :7513.
    • attachFlowSteps (:8391/:8403) used the live node's authored label.
    • REST: packages/rest/src/approvals-routes.ts:214 (list) and :310 (get).
    • Two more doors serve the labels and are deliberately left as they are (see Acceptance notes): the generic data door's list views (sys-approval-request.object.ts:130–:163) and the token action page (action-link-pages.ts:65–:68).
  • H2 holds. The per-request locale is ExecutionContext.locale. The shared assembler sets it to requestLocale ?? localization?.locale (packages/core/src/security/assemble-execution-context.ts:388), and the REST approvals routes pass that context (resolveExecCtx). The i18n service is reachable from plugin-approvals through ctx.getService('i18n'), already used at approvals-plugin.ts:252 for refusal copy.
  • H3 holds. The stored row carries both key parts. process_name is flow: plus Flow.name (the engine's $flowName, approval-node.ts:180, service-automation engine.ts:13331), and current_step is the node id (approval-service.ts:3750). No flow definition is read. A process name without the flow: prefix names no flow, so its labels stay as stored.

Tests

All runs are on head 2335508393 (the merge of origin/main 6440cf1633), under os-verify-lock.sh.

  • New: reader-locale-labels.test.ts (unit, 15 cases).
    • Each key the reader asks is rebuilt into the nested pack shape and parsed by TranslationDataSchema, with a control that a step leaf the face does not declare is refused.
    • A bare zh is negotiated to zh-CN, also against an exact-match service.
    • Picks nothing on a miss, an empty entry, a blank entry, an unheld locale or a throwing service. A read with no locale or no service is no pick at all.
    • One t() per key per read.
    • The overlay moves both step names together and keeps unpicked labels.
  • New: reader-locale-display-labels.integration.test.ts (10 cases). The rig is a real ObjectQL over better-sqlite3, the real AutomationEngine running a two-step flow (so the real approval node opens the request), the real ApprovalsServicePlugin.start over a kernel whose i18n is createMemoryI18n loaded with a zh-CN pack, and the pack parsed by TranslationDataSchema first.
    • A zh-CN reader gets zh-CN labels on the record panel list, the inbox list, the get and its flow_steps, and a decision's echo.
    • A bare zh reader reaches the zh-CN pack.
    • A step the pack lacks is served as stored, beside the translated flow label.
    • A ja-JP reader with no pack gets the stored snapshot.
    • An en reader and a reader with no locale are unchanged.
    • The stored row keeps Account Approval / Manager Review after the reads.
  • Package.
    • pnpm --filter @objectstack/plugin-approvals exec vitest run --maxWorkers=2: Test Files 92 passed (92), Tests 1203 passed (1203).
    • pnpm --filter @objectstack/plugin-approvals typecheck: exit 0. It includes check:test-typecheck: OK, so the new test files compile with no new debt.
    • pnpm --filter @objectstack/plugin-approvals build: exit 0.
    • Closure: turbo run build --filter=@objectstack/plugin-approvals^... --concurrency=2, 32 of 32 successful.
  • Ablation, one leg per changed behaviour, each through scripts/ablation-replace.mjs in WRAP mode with a trap restore. The new tests import the subject by relative path, so they read src/ and no build is needed for either leg.
    • Leg 1 makes readerLabels return nothing (on disk: anchor 1→0, marker 0→1). Result: Tests 6 failed | 19 passed (25). Exactly the six zh-CN pins went red; every as-stored pin and the unit file stayed green.
    • Leg 2 makes attachFlowSteps name steps by the authored label only. Result: 1 failed | 9 passed (10), and the one red was the step-progress pin.
    • Restore after each leg: git checkout HEAD -- PATH. The blob after restore equals HEAD (06899aa721b5), git diff HEAD is empty, and git status --porcelain is clean.
    • The direction was predicted in the test docblock before the run, and it held.

Gates

Local subset, at head 2335508393, under the lock. Each exit code was captured before any pipe.

  • Derivation. node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 70 families (vs merge base 6440cf163, 7 paths). All 70 were run.
  • Results. 69 exited 0. One exited 3:
    • pnpm check:dual-build-cjs-loads answered PREREQUISITE NOT MET. It reads every package's dist/, and 38 packages are unbuilt in this tree. NOT MEASURED locally; CI runs it in Lint & Repo Gates.
  • pnpm check:i18n. It first exited 3 (the CLI was not built). After the prerequisite closure the gate names (turbo run build, 61 of 61 tasks), it exited 0: check-i18n-bundles: OK (10 package(s) — all bundles in sync, no undeclared authoring keys).
  • --ran reconciliation: 70 derived famil(ies) accounted for — 69 run, 1 NOT-MEASURED (1 DERIVED from a recorded exit 3).
  • Left to CI:
    • test:repo: the diff touches none of its files, so it runs in CI's Test Core.
    • pnpm lint and the whole-repo farm.
    • CI check-runs had not concluded when this PR opened.

Open questions (decision frame: real business need, long-term soundness, preventing AI mistakes, no scope creep at the startup stage)

1. The approval.requested notification body (and its siblings)

Measured.

  • The body is built at open (approval-service.ts:3890–:3901): title Approval requested, body A decision on ${subject} is waiting on you., where subject is nameSubjectRecord (the schema label plus the record id).
  • It is emitted once per approver, under the SUBMITTER's request context, so the only locale in hand is the submitter's.
  • The locale it should use is the recipient's. Ruling [Decision] Per-user notification locale —— 2026-08-13 裁决所等的「实测拉力」已到(hotcrm:4 个已发布语言 × 16 个 notify 节点 × 0 可本地化) #13881, as encoded in service-messaging/src/recipient-locale.ts (the stated ONE read point): the recipient's sys_user.locale, then the deployment default, resolved after fan-out.
  • Nothing localizes the sentence today. The sentence has no catalog entry: the Operation Message Catalog (@objectstack/spec/system) holds no notification sentences, and the plugin's packs hold object translations only.
  • So localizing it needs a NEW translation key in packages/spec (or a new plugin catalog). This dispatch forbids both.
  • The same holds for approval.reminder, approval.sla_breached, approval.approved / approval.rejected and the OOO notices.

Options.

  • A. Keyed text, rendered per recipient at delivery. Approvals hands messaging a catalog key and params. Each channel renders the text in the recipient's locale through recipient-locale.ts and the shared catalog, which gains notification entries in en / zh-CN / ja-JP / es-ES.
    • Business need: real (HotCRM measured it; every non-English approver gets the English body).
    • Long-term: best. One locale chain and one catalog serve every producer.
    • AI mistakes: hardest to get wrong. A producer cannot hand-roll a sentence, and a key's existence is checkable.
    • Startup scope: widest. Three lanes (spec catalog, the messaging contract and channels, approvals). Each is a card.
  • B. Rendered at emit, per approver, in plugin-approvals (the fan-out is already one emit per approver). The text comes from new entries in the shared Operation Message Catalog. The recipient's locale comes from messaging (a small read it exposes), so the chain keeps one read point.
    • Business need: real.
    • Long-term: good. Every approval topic reuses one helper, but each other producer would repeat it.
    • AI mistakes: medium. Per-producer rendering is a pattern to copy.
    • Startup scope: two lanes (spec catalog entries, approvals) plus one small messaging read.
  • C. Keep the English bodies and record the gap.
    • Business need: leaves a measured defect.
    • Startup scope: zero cost.

Recommendation: B. It fixes the measured defect at the smallest cost that keeps one catalog and one locale read point. A stays the target if a second non-approval producer asks for localized notifications.

2. The generic data door's list views

Setup → Approvals → Requests and its siblings are list views over sys_approval_request. They serve the stored columns by the #23889 contract, so a zh-CN reader still sees the authored labels there.

  • A. Leave them stored. These are admin lists, the column is the audit record, and ?q= search matches the stored text.
  • B. Add a read-time overlay hook on the data door. Filter and sort would then disagree with the cells, and the authored text would become unreachable through the door.
  • C. Translate in the console from the bundle. That is a client overlay on a served value.

Recommendation: A, until a measured user need appears. HotCRM measured the inbox and the record panel, which this PR fixes.

Acceptance notes

Seat's append (domain:services seat 1, session_011TdZ8GjixNZFLz1xJD9zzK):


Generated by Claude Code

…reader's locale

The approvals door served the request's open-time snapshot of the flow and
step labels to every reader, and the object's schema label on the inbox row.
Pick flows.FLOW.label, flows.FLOW.approvals.NODE.label and
objects.OBJECT.label through the i18n service in ExecutionContext.locale at
read time, falling back to the stored snapshot. The stored columns are not
touched.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK
…label; the door overlays the reader's translation

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Oct 20, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-approvals, touching 35 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/plugins/plugin-approvals/src/display-labels.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

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

  • content/docs/automation/approvals.mdx (via listRequests (symbol, a method of class ApprovalService), /api/v1/approvals/requests (route, the route ledger binds it to client method approvals.listRequests))
  • content/docs/automation/flows.mdx (via ApprovalService (symbol, a top-level class), /api/v1/approvals/requests (route, the route ledger binds it to client method approvals.listRequests))
  • content/docs/automation/webhooks.mdx (via object_name (symbol, a field of interface LocalizableRequestRow))
  • content/docs/data-modeling/import-mappings.mdx (via /:object/import/jobs (route, bridged from symbol object_name — its route source's handler names it))
  • content/docs/deployment/cli.mdx (via object_name (symbol, a field of interface LocalizableRequestRow))
  • content/docs/kernel/runtime-services/audit-service.mdx (via object_name (symbol, a field of interface LocalizableRequestRow))
  • content/docs/kernel/services-checklist.mdx (via getLocales (literal, a string literal in ReaderLabelI18n), /api/v1/approvals/requests (route, the route ledger binds it to client method approvals.listRequests))
  • content/docs/permissions/record-view-auditing.mdx (via object_name (symbol, a field of interface LocalizableRequestRow))
  • content/docs/permissions/system-context.mdx (via /:object/import/jobs (route, bridged from symbol object_name — its route source's handler names it), /import/jobs/:jobId/undo (route, bridged from symbol object_name — its route source's handler names it))
  • content/docs/protocol/kernel/http-protocol.mdx (via object_name (symbol, a field of interface LocalizableRequestRow), getLocales (literal, a string literal in ReaderLabelI18n))
  • content/docs/protocol/kernel/i18n-standard.mdx (via object_name (symbol, a field of interface LocalizableRequestRow))
  • content/docs/protocol/objectql/state-machine.mdx (via /:object/import/jobs (route, bridged from symbol object_name — its route source's handler names it), /import/jobs/:jobId/undo (route, bridged from symbol object_name — its route source's handler names it))

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

  • content/docs/releases/v16.mdx (via listRequests (symbol, a method of class ApprovalService))
  • content/docs/releases/v17/17-0.mdx (via listRequests (symbol, a method of class ApprovalService))
  • content/docs/releases/v17/17-6.mdx (via ApprovalService (symbol, a top-level class), flowLabel (symbol, a method of interface ReaderLabels), object_name (symbol, a field of interface LocalizableRequestRow))

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
  • 1 changed file(s) yielded no anchor (packages/plugins/plugin-approvals/src/display-labels.ts) — pages documenting those are invisible to this run
  • 2 name(s) were too generic to anchor anything (single lowercase words)
  • 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 — 6 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 6440cf16334e1c8106dfdb31d87b6ca728d176c2 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 6440cf16334e1c8106dfdb31d87b6ca728d176c2

⚠️ 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 6440cf16334e1c8106dfdb31d87b6ca728d176c2 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对 · ACCEPT · domain:services seat 1 · 2026-10-20T20:39Z

os-justin · session_011TdZ8GjixNZFLz1xJD9zzK (seat post #6021). Reviewed against GitHub and origin/main f32d795ce5, not against the report (os-dev report 6300567414 on #24863). Head under review:

2335508393a37cc3463b8b6721c9434d71264995
  1. Shape.
  2. The route, read in the code.
    • reader-locale-labels.ts negotiates the reader's ExecutionContext.locale with resolveBundleLocale over getLocales(), the mechanism objectql, rest and the automation engine already use. It picks flows.FLOW.label, flows.FLOW.approvals.NODE.label (the PR feat(spec,lint): flows translation face gains approvals.NODE.label for an approval node's step label #24943 key) and objects.OBJECT.label through II18nService.t().
    • A miss (the key echoed back), an empty answer or a throwing service leaves the stored snapshot standing.
    • approval-service.ts applies the overlay in listRequests and loadRequest, and attachFlowSteps names each step with the same pick. So one response never names the current step in two languages.
    • Stored columns, write paths and read visibility are unchanged. No translation key or catalog entry is added.
  3. Premises.
    • H1 holds: every door serving the labels is listed in the PR body with file:line.
    • H2 holds: the locale is on the context, and the kernel's i18n service is reachable through the plugin's lazy reader.
    • H3 holds: the row keeps the flow's machine name and the node id.
  4. Pins and ablation.
    • reader-locale-labels.test.ts: 15 unit cases.
    • reader-locale-display-labels.integration.test.ts: 10 cases on a real ObjectQL over better-sqlite3, the real AutomationEngine running a two-step flow, the real plugin start, and a zh-CN pack that TranslationDataSchema accepts. They cover the record panel, the inbox, bare zh, the get plus flow_steps, the decision echo, an untranslated step beside a translated flow, ja-JP, en, no locale, and the stored row.
    • Ablation leg 1 (readerLabels returns nothing): exactly the six zh-CN pins go red.
    • Ablation leg 2 (attachFlowSteps uses the authored label only): the step-progress pin goes red.
    • Both restores were proven by blob equality.
  5. Changeset, @objectstack/plugin-approvals patch, read sentence by sentence: it matches 2–4 and states the stored columns are unchanged.
  6. Gates.
    • At ACCEPT, every check-run on this head is success or an expected skip, among them TypeScript Type Check, Lint & Repo Gates, Test Core 1–6/6, Build Core, Governed Surface Queue Guard and Check Documentation Links.
    • Locally, 69 of 70 derived families exited 0, with check:i18n re-run after its closure build. 1 was NOT MEASURED (check:dual-build-cjs-loads, exit 3, prerequisite).
    • plugin-approvals: 92 files, 1203 tests passed.
  7. Seat rulings on the report's open questions (6300710414).
  8. Accepted deviations.
  9. Out-of-scope dispositions.
  10. Landing clause. Tier S: not governed, under the human-merge threshold. Next step: ready + auto-merge through the relay once every check on the current head is green or an expected skip. A push resets this reading.

Generated by Claude Code

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/l tests tooling

Projects

None yet

2 participants