You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
plugin-approvals: the approval notifications (approval.requested, reminder, sla_breached, approved/rejected, out-of-office) reach every approver as literal English sentences built at emit; no catalog holds them, so no recipient locale can apply #24978
① class a, measured on HotCRM (objectstack-ai/hotcrm#2060 item 13). This is the notification half of #24863. That card's services half, the request labels, is PR #24977. Filed by the domain:services PM seat, seat 1 (seat post #6021, session_011TdZ8GjixNZFLz1xJD9zzK), from #24863's dev report (open question 1). Unlabelled and ungraded for triage.
Reader, and what they do: triage grades and routes it. The fix spans the spec message catalog, service-messaging and plugin-approvals. If the chosen route changes a messaging contract or adds catalog keys, it may be protocol class, which would make it the maintainer's to rule.
The finding, as measured on origin/main693352e4c2
Where the sentence is built.plugin-approvals builds the approval.requested body at open (approval-service.ts:3890-3901): title "Approval requested", body "A decision on SUBJECT is waiting on you." SUBJECT is nameSubjectRecord, the schema label plus the record id.
It emits once per approver, under the SUBMITTER's request context.
The siblings approval.reminder, approval.sla_breached, approval.approved / approval.rejected and the out-of-office notices are built the same way.
No catalog holds them. No catalog or pack holds these sentences, so a zh-CN, ja-JP or es-ES approver reads English.
A. Keyed text, rendered per recipient at delivery. Approvals hands messaging a catalog key and params. Each channel renders it in the recipient's locale through recipient-locale.ts and the shared catalog, which gains en / zh-CN / ja-JP / es-ES entries.
Cost: widest, a messaging contract change across three lanes.
B. Rendered at emit, per approver, in plugin-approvals, with new catalog entries and a small messaging read for the recipient's locale.
Cost: two lanes plus one read.
Weakness: every other producer repeats the helper.
C. Keep English and record the gap. This leaves the measured defect in place.
The seat's reading. By decision-analysis.md, the recommendation follows axis ① and cost only stages it, so the reading is A. The #24863 dev recommended B on cost, and B is the stageable subset. The seat does not rule the route here, because A changes a messaging contract. Triage grades it, and the maintainer rules if it is protocol class.
Dedupe: query run by this seat, hit count including closed
approval.requested notification body literal English recipient locale localized approval reminder notification translation → 4 hits, all closed:
The maintainer's standing word 「平台相关的功能,应该尽量先改平台」 points the same way: one catalog path in service-messaging that every emitter can use.
B renders per approver inside plugin-approvals and duplicates that path for one emitter.
The contract:notify accepts a catalog key plus params beside today's literal text. That is a widening, declared as Clause-②: yes (widening) and reviewed at tier. Under 「甲」 it ships in an 18.x minor. If the contract review reads more than a widening, the seat brings the delta back as a decision request instead of narrowing silently.
Scope:approval.requested, reminder, sla_breached, approved / rejected and the out-of-office notices, rendered through recipient-locale.ts with en / zh-CN / ja-JP / es-ES entries. The literal text stays as the fallback for an emitter that passes no key.
Claim: PM loop round 20 · 2026-10-20T22:31Z
Session: session_011TdZ8GjixNZFLz1xJD9zzK
Account: os-justin (the seat's linked user as GET /user answers it; always the card's assignee)
Branch: claude/issue-24978-approval-notifications-recipient-locale
Worktree: objectstack-issue-24978
Domain: domain:services
Seat: domain:services#1
File surface:
packages/services/service-messaging/src/**: notify / EmitInput accepting a catalog key plus params beside the literal text, the catalog with its en / zh-CN / ja-JP / es-ES entries, and the per-recipient render at delivery through recipient-locale.ts;
packages/plugins/plugin-approvals/src/**: the approval notification emitters, passing keys;
NEW tests in both packages;
new .changeset/24978-*.md files.
⛔ Not packages/spec/**; if the honest home of the contract or the catalog is spec, report a fork. ⛔ Not the 34 plugin-approvals test files PR #24940 edits. ⛔ No change to who receives a notification. ⛔ The literal text stays the fallback (stop on breach; explain in the report)
Container & model: M, mode:subagent, model: default (opus), quoting the --tier output "no path-derived mandate … floor sonnet · default opus · ceiling fable"
Clause-②: yes (widening)
Responsibility: plugin-approvals builds its notification sentences as English literals at emit, and service-messaging renders only literal text, so no recipient locale applies | #22507's ruling places the render where the string is rendered, which for a notification is delivery per recipient (triage R547) | every non-English approver; measured on HotCRM
Thread-read: 6302578913
Serial constraints cleared: No open PR touches service-messaging or plugin-approvals' emitters (all 16 open PRs' files read at this claim). PR #24940 (the epic's 7b-0) edits 34 plugin-approvals test files; new pins go in new files. #24977 (#24863, labels) landed as ba69b168be; #24924 (seat 2, template channels) landed.
The route is triage's ruling A (R547, 6302578913):notify accepts a catalog key plus params beside today's literal text, a widening reviewed at tier. Under 「甲」 it ships in an 18.x minor. If the contract review reads more than a widening, the seat brings the delta back as a decision request.
{
"issue": 24978,
"status": "done",
"branch": "claude/issue-24978-approval-notifications-recipient-locale",
"pr": "https://github.com/objectstack-ai/objectstack/pull/25014",
"session": "session_011TdZ8GjixNZFLz1xJD9zzK — the harness-stamped id of this run (subagent = parent's)",
"premise_still_valid": true,
"summary": "Route A as triage ruled it. service-messaging's EmitInput gains an optional keyedText ({ titleKey?, bodyKey?, params? }; a param is a string or { key, fallback }). A new NotificationTextRenderer renders it per recipient at delivery, before the channel is called: in MessagingService.fanOut (inline), in NotificationDispatcher.processRow, and per row of a digest group. The language is the #13881 chain through resolveRecipientLocale (sys_user.locale, then the deployment default), negotiated with resolveBundleLocale. Each member falls back to the literal payload.title/body when it cannot be rendered. A malformed keyedText is refused at emit with a TypeError before any write, and the delivery snapshot reserves the name keyedText. The catalog is the existing i18n service catalog (the messages face, ADR-0029 D8 bundles, the shape plugin-audit already uses); no second catalog and no spec change. The Operation Message Catalog was measured and not reused: it is the spec-resident errors.ID catalog of refusals, and the spec is out of bounds. plugin-approvals passes keys at all 14 notify() call sites. Its sentences are in en/zh-CN/ja-JP/es-ES in its own bundle (translations/messages.ts), and the literal payload is the en entries interpolated, byte-identical to before. The object label is a keyed param (objects.OBJECT.label, falling back to the schema label), so the subject keeps the ruling B form and is localized when the app's pack holds the label. The five siblings outside the named six (escalated, returned x2, reassigned, request_info, comment) are converted under the bounded in-place exemption (all four conditions hold; named in the PR body). Measured: H1 holds, except that sla_breached (approvers), escalated and comment are single multi-recipient emits; the delivery-time render covers both shapes. H2 holds as one chain function, not one read: email/SMS read the locale with the address and inbox only on the template path, and keyed text adds one read through the same chain. H3 holds and is pinned against a renderer-less service. The worktree is removed after this report is posted.",
"tests": "Head 1408bfb197 (the branch with origin/main fbb42d900f merged), closures rebuilt first, all under os-verify-lock: `pnpm --filter @objectstack/service-messaging test` gives 'Test Files 55 passed (55) / Tests 618 passed (618)'; `pnpm --filter @objectstack/plugin-approvals test` gives 'Test Files 93 passed (93) / Tests 1208 passed (1208)'; `typecheck` exit 0 in both packages (approvals: 'check:test-typecheck: OK'). New pins: packages/services/service-messaging/src/keyed-notification-text.integration.test.ts (10 cases: zh-CN sentence with localized object label inline, bare zh reaching zh-CN, no locale reaching the deployment default, a ja-JP miss with no declared fallback delivering the literal and said once, the declared fallback locale answering, a key held by no locale delivering the literal member by member, a literal-only emit byte-unchanged against a renderer-less service with 0 locale reads, the durable path rendering zh-CN on inbox/email/SMS with keys+literal stored in the snapshot, the reserved snapshot name, digest items rendered, a malformed keyedText refused with nothing written) and packages/plugins/plugin-approvals/src/approval-notification-keyed-text.integration.test.ts (5 cases: the battery reaches all 12 topics; each emit carries a catalog title key, and a body key exactly where the body is the service's own sentence; the keys render to the literal in en and to another sentence naming the localized record in zh-CN/ja-JP/es-ES through the real renderer and the plugin's own bundle; locale parity of ids and holes; end to end with a real MessagingService and inbox: zh-CN, en and no-locale-to-ja-JP approvers, and the zh-CN submitter's approved notice). Ablation on committed head cbff799d08, one leg per pinned behaviour (L1 inline render, L2/L2b deployment default, L3 miss falls back, L4 literal-only unchanged, L5 each site passes its key, L6 durable render, L7 digest items, L8 reserved snapshot name, L9 refused at emit, L10 object label keyed). Each leg went through scripts/ablation-replace.mjs (anchor 1 to 0, blob changed: e.g. L1 messaging-service.ts 6cc7d3ac42ec to 9ef3fe094241) and each turned its pin red (L1 4/10, L2 1/10 plus approvals 1/5, L3 5/10, L4 1/10, L5 approvals 3/5, L6 1/10, L7 1/10, L8 1/10, L9 1/10, L10 approvals 2/5). Every leg was restored by `git checkout HEAD -- ABS_PATH`, proven blob == HEAD and `git diff HEAD` empty. A closing pass re-proved all five files. All subjects resolve through source (relative imports, or the new anchored source alias for @objectstack/service-messaging), so no dist was involved.",
"gates": "Local subset at 1408bfb197: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 84 families. All 84 ran sequentially under the verify lock, each exit captured before any pipe: 84 x exit 0, including check:i18n ('OK (10 package(s) — all bundles in sync)'), check:dual-build-cjs-loads ('112 published require entry point(s) across 69 package(s) load'), check:query-options-erasure ('ratchet holds ... test surface: 236'), check:test-source-alias, check:cross-package-test-inputs, check:doc-authoring, check:nul-bytes, check:changeset-no-major and check:adr-0087-registration. `--ran` with exits recorded: 'Run reconciliation — 84 derived, 84 run, 0 NOT-MEASURED, 0 UNRUN' (a derived zero, since all 84 carry an exit code). An earlier union on pre-merge head 8149493c30 read query-options-erasure exit 1 (test surface 236 to 237, from a new test's any-typed proxy options; fixed in cbff799d08) and check:i18n / check:dual-build-cjs-loads exit 3 PREREQUISITE NOT MET (not a measurement; both measured green on the final head). Outside the derived set: eslint --no-inline-config over the 11 changed source/test files exit 0, and the control-byte self-scan found nothing. CI (PR #25014 head 1408bfb197) at report time: 39 check-runs, 7 success, 6 skipped, 26 in_progress, including 'Build Core' in_progress and 'Governed Surface Queue Guard' in_progress. Lint & Repo Gates, TypeScript Type Check and Test Core were not yet listed: in_progress, to be read by the seat.",
"mcp_calls": "0 — no MCP tool was called",
"api_writes": "2 relay strokes (scripts/pm/fleet-write/dispatch.mjs, POST /repos/objectstack-ai/objectstack/dispatches each): (1) pr_create, which the relay executed as POST /repos/objectstack-ai/objectstack/pulls (draft) plus POST /repos/objectstack-ai/objectstack/issues/25014/assignees ['os-justin'], with the body read back byte-identical (14542 sent, 14542 stored); (2) this os-dev-report comment, POST /repos/objectstack-ai/objectstack/issues/24978/comments. Plus git pushes of the branch (not REST writes): the empty-branch probe, five WIP/feature commits and the main merge. Reads were single-card REST GETs (issue, comments, PR, check-runs).",
"deviations": [
"Gate runs went under os-verify-lock as the dispatch asks (os-dev.md only notes that check:* gates are not covered by the lock; no conflict). The final verify held the lock 1340s (22m20s): closures, both suites, typechecks and the 84-family union in one acquisition, flagged by the lock's own long-holder warning.",
"Lockfile: `pnpm install` also reshuffled two unrelated importers' esbuild peer versions (0.28.1 and 0.28.2). Those hunks were discarded and only the plugin-approvals importer entry was kept (3 lines), verified with `pnpm install --frozen-lockfile` exit 0.",
"plugin-approvals gains @objectstack/service-messaging as a devDependency (the shape service-automation already has) and an anchored source alias in vitest.config.ts, so its new end-to-end pin drives a real MessagingService (check:test-source-alias green).",
"origin/main (fbb42d900f) was merged before opening the PR (AGENTS §10). No upstream commit since the base touched service-messaging, plugin-approvals, core, spec/system or the lockfile.",
"Scope widened in place to five sibling notifications (escalated, returned x2, reassigned, request_info, comment) under the bounded exemption; named in the PR body.",
"Commits carry AGENTS' model-free trailer pair rather than the harness's model-named Co-Authored-By line.",
"The messaging changeset carries 'Clause-②: yes (widening)'. The approvals changeset (patch) carries no Clause line: its accept set and exports are unchanged, and only the non-exported structural emit input gains the optional member."
],
"files_changed": [
".changeset/24978-approval-notifications-recipient-language.md",
".changeset/24978-keyed-notification-text.md",
"packages/plugins/plugin-approvals/package.json",
"packages/plugins/plugin-approvals/src/approval-notification-keyed-text.integration.test.ts",
"packages/plugins/plugin-approvals/src/approval-notification-text.ts",
"packages/plugins/plugin-approvals/src/approval-service.ts",
"packages/plugins/plugin-approvals/src/translations/index.ts",
"packages/plugins/plugin-approvals/src/translations/messages.ts",
"packages/plugins/plugin-approvals/vitest.config.ts",
"packages/services/service-messaging/src/dispatcher.ts",
"packages/services/service-messaging/src/fan-out-system-context.ts",
"packages/services/service-messaging/src/index.ts",
"packages/services/service-messaging/src/keyed-notification-text.integration.test.ts",
"packages/services/service-messaging/src/messaging-service-plugin.ts",
"packages/services/service-messaging/src/messaging-service.ts",
"packages/services/service-messaging/src/notification-text.ts",
"packages/services/service-messaging/src/outbox.ts",
"packages/services/service-messaging/src/recipient-locale.ts",
"pnpm-lock.yaml"
],
"open_questions": [
{
"question": "The delivery snapshot now reserves the name keyedText: a producer payload member of that name is dropped from the durable snapshot instead of riding to the channels. Measured: no producer in the repository writes one (git grep outside the two packages: 0). Does the contract-tier review read this as part of the widening, or as a delta to bring back?",
"options": [
"A — keep the reservation as shipped: the same rule the snapshot already applies to title/body/severity/actionUrl/actorId",
"B — do not strip: a producer's payload.keyedText would then be rendered on the durable path only, unvalidated, and ignored inline",
"C — refuse at emit any payload carrying keyedText: loud, but it narrows emit's accept set for a name nobody uses"
],
"recommendation": "A. Business need: zero measured producers, so no real scenario loses anything. Long-term: one owner for the snapshot's names, the same rule as its five existing reserved names. AI-error: B is the trap shape, an AI-authored flow payload that half-works on one path; C is louder but spends an accept-set narrowing on nothing; A makes keyed rendering reachable only through the typed, validated member. Startup scope: A adds no surface and no gate."
}
],
"out_of_scope_findings": [
"class: a · reach: named producer: renderDigest (packages/services/service-messaging/src/digest-render.ts) titles every digest group of 2+ rows 'You have N notifications', an English literal, for every recipient. NotificationDispatcher.processDigestGroup reaches it whenever a recipient's sys_notification_preference.digest is daily or weekly (written through the data API). Observed in this PR's digest pin, where the items render zh-CN under that English heading · evidence: digest-render.ts `const title = count === 1 ? items[0].title : ...`, no catalog key · dedupe words: digest title English · renderDigest locale · You have notifications literal",
"carrier: none (承接者:无) · @objectstack/plugin-audit's collab.mention title is still rendered at emit, through resolveWriteLocale (the ADR-0053 localization context), not per recipient at delivery through the #13881 chain. It could move to EmitInput.keyedText now. Noted in the PR's Acceptance notes, not filed."
]
}
State change in this stroke: pm:dispatched off and the assignee cleared. The card closed completed on the merge.
What changes: a notification's text can be catalog keys, rendered per recipient at delivery in that recipient's language (sys_user.locale, then the deployment default), with the literal text as the fallback. Every approval notification passes its keys, in en / zh-CN / ja-JP / es-ES. The English is byte-identical to before (ACCEPT 6305329865).
Follow-up:#25015, the digest heading, which is still an English literal for every recipient. It is filed for triage.
Activity
objectstack-fleet commentedon Oct 20, 2026
Triage (R547): p2 ·
domain:services·area:i18n·bug·pm:queue. Route: A, as triage's derivation (ruling B on #24589). ·session_01AavokzJ5DndAwitDXvKy4U· 2026-10-20T21:55Zdescriptionhave no key in theflowstranslation face — the zh-CN intake wizard translates its labels and placeholders and keeps its options and body text in English (17.7.0) #22507's ruling (6093472708, 「22507 同意」) says a server-rendered string that a user reads is translated where it is rendered, in that run's language. A notification reaches several recipients, so its render site is delivery, per recipient.service-messagingthat every emitter can use.plugin-approvalsand duplicates that path for one emitter.notifyaccepts a catalog key plus params beside today's literal text. That is a widening, declared asClause-②: yes (widening)and reviewed at tier. Under 「甲」 it ships in an 18.x minor. If the contract review reads more than a widening, the seat brings the delta back as a decision request instead of narrowing silently.approval.requested, reminder,sla_breached, approved / rejected and the out-of-office notices, rendered throughrecipient-locale.tswith en / zh-CN / ja-JP / es-ES entries. The literal text stays as the fallback for an emitter that passes no key.Generated by Claude Code
objectstack-fleet commentedon Oct 20, 2026
Claim: PM loop round 20 · 2026-10-20T22:31Z
Session:
session_011TdZ8GjixNZFLz1xJD9zzKAccount:
os-justin(the seat's linked user asGET /useranswers it; always the card's assignee)Branch:
claude/issue-24978-approval-notifications-recipient-localeWorktree:
objectstack-issue-24978Domain:
domain:servicesSeat:
domain:services#1File surface:
packages/services/service-messaging/src/**:notify/EmitInputaccepting a catalog key plus params beside the literal text, the catalog with its en / zh-CN / ja-JP / es-ES entries, and the per-recipient render at delivery throughrecipient-locale.ts;packages/plugins/plugin-approvals/src/**: the approval notification emitters, passing keys;.changeset/24978-*.mdfiles.⛔ Not
packages/spec/**; if the honest home of the contract or the catalog is spec, report a fork. ⛔ Not the 34plugin-approvalstest files PR #24940 edits. ⛔ No change to who receives a notification. ⛔ The literal text stays the fallback (stop on breach; explain in the report)Container & model:
M,mode:subagent,model: default (opus), quoting the --tier output "no path-derived mandate … floor sonnet · default opus · ceiling fable"Clause-②: yes (widening)
Responsibility:
plugin-approvals builds its notification sentences as English literals at emit, and service-messaging renders only literal text, so no recipient locale applies | #22507's ruling places the render where the string is rendered, which for a notification is delivery per recipient (triage R547) | every non-English approver; measured on HotCRMThread-read: 6302578913
Serial constraints cleared:
No open PR touches service-messaging or plugin-approvals' emitters (all 16 open PRs' files read at this claim). PR #24940 (the epic's 7b-0) edits 34 plugin-approvals test files; new pins go in new files. #24977 (#24863, labels) landed as ba69b168be; #24924 (seat 2, template channels) landed.The route is triage's ruling A (R547,
6302578913):notifyaccepts a catalog key plus params beside today's literal text, a widening reviewed at tier. Under 「甲」 it ships in an 18.x minor. If the contract review reads more than a widening, the seat brings the delta back as a decision request.Readings taken on
origin/main7adfaec2a1.Generated by Claude Code
objectstack-fleet commentedon Oct 21, 2026
os-dev-report
{ "issue": 24978, "status": "done", "branch": "claude/issue-24978-approval-notifications-recipient-locale", "pr": "https://github.com/objectstack-ai/objectstack/pull/25014", "session": "session_011TdZ8GjixNZFLz1xJD9zzK — the harness-stamped id of this run (subagent = parent's)", "premise_still_valid": true, "summary": "Route A as triage ruled it. service-messaging's EmitInput gains an optional keyedText ({ titleKey?, bodyKey?, params? }; a param is a string or { key, fallback }). A new NotificationTextRenderer renders it per recipient at delivery, before the channel is called: in MessagingService.fanOut (inline), in NotificationDispatcher.processRow, and per row of a digest group. The language is the #13881 chain through resolveRecipientLocale (sys_user.locale, then the deployment default), negotiated with resolveBundleLocale. Each member falls back to the literal payload.title/body when it cannot be rendered. A malformed keyedText is refused at emit with a TypeError before any write, and the delivery snapshot reserves the name keyedText. The catalog is the existing i18n service catalog (the messages face, ADR-0029 D8 bundles, the shape plugin-audit already uses); no second catalog and no spec change. The Operation Message Catalog was measured and not reused: it is the spec-resident errors.ID catalog of refusals, and the spec is out of bounds. plugin-approvals passes keys at all 14 notify() call sites. Its sentences are in en/zh-CN/ja-JP/es-ES in its own bundle (translations/messages.ts), and the literal payload is the en entries interpolated, byte-identical to before. The object label is a keyed param (objects.OBJECT.label, falling back to the schema label), so the subject keeps the ruling B form and is localized when the app's pack holds the label. The five siblings outside the named six (escalated, returned x2, reassigned, request_info, comment) are converted under the bounded in-place exemption (all four conditions hold; named in the PR body). Measured: H1 holds, except that sla_breached (approvers), escalated and comment are single multi-recipient emits; the delivery-time render covers both shapes. H2 holds as one chain function, not one read: email/SMS read the locale with the address and inbox only on the template path, and keyed text adds one read through the same chain. H3 holds and is pinned against a renderer-less service. The worktree is removed after this report is posted.", "tests": "Head 1408bfb197 (the branch with origin/main fbb42d900f merged), closures rebuilt first, all under os-verify-lock: `pnpm --filter @objectstack/service-messaging test` gives 'Test Files 55 passed (55) / Tests 618 passed (618)'; `pnpm --filter @objectstack/plugin-approvals test` gives 'Test Files 93 passed (93) / Tests 1208 passed (1208)'; `typecheck` exit 0 in both packages (approvals: 'check:test-typecheck: OK'). New pins: packages/services/service-messaging/src/keyed-notification-text.integration.test.ts (10 cases: zh-CN sentence with localized object label inline, bare zh reaching zh-CN, no locale reaching the deployment default, a ja-JP miss with no declared fallback delivering the literal and said once, the declared fallback locale answering, a key held by no locale delivering the literal member by member, a literal-only emit byte-unchanged against a renderer-less service with 0 locale reads, the durable path rendering zh-CN on inbox/email/SMS with keys+literal stored in the snapshot, the reserved snapshot name, digest items rendered, a malformed keyedText refused with nothing written) and packages/plugins/plugin-approvals/src/approval-notification-keyed-text.integration.test.ts (5 cases: the battery reaches all 12 topics; each emit carries a catalog title key, and a body key exactly where the body is the service's own sentence; the keys render to the literal in en and to another sentence naming the localized record in zh-CN/ja-JP/es-ES through the real renderer and the plugin's own bundle; locale parity of ids and holes; end to end with a real MessagingService and inbox: zh-CN, en and no-locale-to-ja-JP approvers, and the zh-CN submitter's approved notice). Ablation on committed head cbff799d08, one leg per pinned behaviour (L1 inline render, L2/L2b deployment default, L3 miss falls back, L4 literal-only unchanged, L5 each site passes its key, L6 durable render, L7 digest items, L8 reserved snapshot name, L9 refused at emit, L10 object label keyed). Each leg went through scripts/ablation-replace.mjs (anchor 1 to 0, blob changed: e.g. L1 messaging-service.ts 6cc7d3ac42ec to 9ef3fe094241) and each turned its pin red (L1 4/10, L2 1/10 plus approvals 1/5, L3 5/10, L4 1/10, L5 approvals 3/5, L6 1/10, L7 1/10, L8 1/10, L9 1/10, L10 approvals 2/5). Every leg was restored by `git checkout HEAD -- ABS_PATH`, proven blob == HEAD and `git diff HEAD` empty. A closing pass re-proved all five files. All subjects resolve through source (relative imports, or the new anchored source alias for @objectstack/service-messaging), so no dist was involved.", "gates": "Local subset at 1408bfb197: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 84 families. All 84 ran sequentially under the verify lock, each exit captured before any pipe: 84 x exit 0, including check:i18n ('OK (10 package(s) — all bundles in sync)'), check:dual-build-cjs-loads ('112 published require entry point(s) across 69 package(s) load'), check:query-options-erasure ('ratchet holds ... test surface: 236'), check:test-source-alias, check:cross-package-test-inputs, check:doc-authoring, check:nul-bytes, check:changeset-no-major and check:adr-0087-registration. `--ran` with exits recorded: 'Run reconciliation — 84 derived, 84 run, 0 NOT-MEASURED, 0 UNRUN' (a derived zero, since all 84 carry an exit code). An earlier union on pre-merge head 8149493c30 read query-options-erasure exit 1 (test surface 236 to 237, from a new test's any-typed proxy options; fixed in cbff799d08) and check:i18n / check:dual-build-cjs-loads exit 3 PREREQUISITE NOT MET (not a measurement; both measured green on the final head). Outside the derived set: eslint --no-inline-config over the 11 changed source/test files exit 0, and the control-byte self-scan found nothing. CI (PR #25014 head 1408bfb197) at report time: 39 check-runs, 7 success, 6 skipped, 26 in_progress, including 'Build Core' in_progress and 'Governed Surface Queue Guard' in_progress. Lint & Repo Gates, TypeScript Type Check and Test Core were not yet listed: in_progress, to be read by the seat.", "mcp_calls": "0 — no MCP tool was called", "api_writes": "2 relay strokes (scripts/pm/fleet-write/dispatch.mjs, POST /repos/objectstack-ai/objectstack/dispatches each): (1) pr_create, which the relay executed as POST /repos/objectstack-ai/objectstack/pulls (draft) plus POST /repos/objectstack-ai/objectstack/issues/25014/assignees ['os-justin'], with the body read back byte-identical (14542 sent, 14542 stored); (2) this os-dev-report comment, POST /repos/objectstack-ai/objectstack/issues/24978/comments. Plus git pushes of the branch (not REST writes): the empty-branch probe, five WIP/feature commits and the main merge. Reads were single-card REST GETs (issue, comments, PR, check-runs).", "deviations": [ "Gate runs went under os-verify-lock as the dispatch asks (os-dev.md only notes that check:* gates are not covered by the lock; no conflict). The final verify held the lock 1340s (22m20s): closures, both suites, typechecks and the 84-family union in one acquisition, flagged by the lock's own long-holder warning.", "Lockfile: `pnpm install` also reshuffled two unrelated importers' esbuild peer versions (0.28.1 and 0.28.2). Those hunks were discarded and only the plugin-approvals importer entry was kept (3 lines), verified with `pnpm install --frozen-lockfile` exit 0.", "plugin-approvals gains @objectstack/service-messaging as a devDependency (the shape service-automation already has) and an anchored source alias in vitest.config.ts, so its new end-to-end pin drives a real MessagingService (check:test-source-alias green).", "origin/main (fbb42d900f) was merged before opening the PR (AGENTS §10). No upstream commit since the base touched service-messaging, plugin-approvals, core, spec/system or the lockfile.", "Scope widened in place to five sibling notifications (escalated, returned x2, reassigned, request_info, comment) under the bounded exemption; named in the PR body.", "Commits carry AGENTS' model-free trailer pair rather than the harness's model-named Co-Authored-By line.", "The messaging changeset carries 'Clause-②: yes (widening)'. The approvals changeset (patch) carries no Clause line: its accept set and exports are unchanged, and only the non-exported structural emit input gains the optional member." ], "files_changed": [ ".changeset/24978-approval-notifications-recipient-language.md", ".changeset/24978-keyed-notification-text.md", "packages/plugins/plugin-approvals/package.json", "packages/plugins/plugin-approvals/src/approval-notification-keyed-text.integration.test.ts", "packages/plugins/plugin-approvals/src/approval-notification-text.ts", "packages/plugins/plugin-approvals/src/approval-service.ts", "packages/plugins/plugin-approvals/src/translations/index.ts", "packages/plugins/plugin-approvals/src/translations/messages.ts", "packages/plugins/plugin-approvals/vitest.config.ts", "packages/services/service-messaging/src/dispatcher.ts", "packages/services/service-messaging/src/fan-out-system-context.ts", "packages/services/service-messaging/src/index.ts", "packages/services/service-messaging/src/keyed-notification-text.integration.test.ts", "packages/services/service-messaging/src/messaging-service-plugin.ts", "packages/services/service-messaging/src/messaging-service.ts", "packages/services/service-messaging/src/notification-text.ts", "packages/services/service-messaging/src/outbox.ts", "packages/services/service-messaging/src/recipient-locale.ts", "pnpm-lock.yaml" ], "open_questions": [ { "question": "The delivery snapshot now reserves the name keyedText: a producer payload member of that name is dropped from the durable snapshot instead of riding to the channels. Measured: no producer in the repository writes one (git grep outside the two packages: 0). Does the contract-tier review read this as part of the widening, or as a delta to bring back?", "options": [ "A — keep the reservation as shipped: the same rule the snapshot already applies to title/body/severity/actionUrl/actorId", "B — do not strip: a producer's payload.keyedText would then be rendered on the durable path only, unvalidated, and ignored inline", "C — refuse at emit any payload carrying keyedText: loud, but it narrows emit's accept set for a name nobody uses" ], "recommendation": "A. Business need: zero measured producers, so no real scenario loses anything. Long-term: one owner for the snapshot's names, the same rule as its five existing reserved names. AI-error: B is the trap shape, an AI-authored flow payload that half-works on one path; C is louder but spends an accept-set narrowing on nothing; A makes keyed rendering reachable only through the typed, validated member. Startup scope: A adds no surface and no gate." } ], "out_of_scope_findings": [ "class: a · reach: named producer: renderDigest (packages/services/service-messaging/src/digest-render.ts) titles every digest group of 2+ rows 'You have N notifications', an English literal, for every recipient. NotificationDispatcher.processDigestGroup reaches it whenever a recipient's sys_notification_preference.digest is daily or weekly (written through the data API). Observed in this PR's digest pin, where the items render zh-CN under that English heading · evidence: digest-render.ts `const title = count === 1 ? items[0].title : ...`, no catalog key · dedupe words: digest title English · renderDigest locale · You have notifications literal", "carrier: none (承接者:无) · @objectstack/plugin-audit's collab.mention title is still rendered at emit, through resolveWriteLocale (the ADR-0053 localization context), not per recipient at delivery through the #13881 chain. It could move to EmitInput.keyedText now. Noted in the PR's Acceptance notes, not filed." ] }Generated by Claude Code
objectstack-fleet commentedon Oct 21, 2026
ACCEPT → PR #25014 comment
6305329865·domain:servicesseat 1 ·session_011TdZ8GjixNZFLz1xJD9zzK· 2026-10-21T00:59ZGenerated by Claude Code
objectstack-fleet commentedon Oct 21, 2026
Landed ·
domain:servicesseat 1 ·session_011TdZ8GjixNZFLz1xJD9zzK· 2026-10-21T01:29Zadded_to_merge_queue2026-10-21T01:02Z →merged2026-10-21T01:28Zorigin/mainby content58cd58fa6c(one parent,05b28c199b), 19 files.EmitInputcarrieskeyedText?: NotificationTextKeysState change in this stroke:
pm:dispatchedoff and the assignee cleared. The card closedcompletedon the merge.What changes: a notification's text can be catalog keys, rendered per recipient at delivery in that recipient's language (
sys_user.locale, then the deployment default), with the literal text as the fallback. Every approval notification passes its keys, in en / zh-CN / ja-JP / es-ES. The English is byte-identical to before (ACCEPT6305329865).Follow-up: #25015, the digest heading, which is still an English literal for every recipient. It is filed for triage.
Generated by Claude Code