Skip to content

feat(service-messaging,plugin-approvals): keyed notification text, rendered per recipient at delivery, so approval notifications reach each recipient in their own language - #25014

Merged
objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-24978-approval-notifications-recipient-locale
Oct 21, 2026
Merged

objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-24978-approval-notifications-recipient-locale

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #24978

Clause-②: yes (widening)

What this does

The approval notifications reached every approver as English sentences built at emit, and no catalog held them. This PR follows triage's route A (R547): the messaging service now takes a notification's text as catalog keys, beside the literal text, and renders it per recipient at delivery in that recipient's language. The approvals plugin passes keys for every notification it sends.

The contract change, sentence by sentence (for the contract-tier review)

Accept set of MessagingService.emit(input: EmitInput):

  1. Widened. EmitInput.keyedText?: { titleKey?: string; bodyKey?: string; params?: Record(string, string or { key: string; fallback: string }) } is a new optional member. Every input accepted before is still accepted.
  2. Closed shape, refused at emit. A keyedText that is present but malformed throws TypeError before any row is written. The cases are an undeclared member, a key that is not a dot path of at least two segments (a face and an id), a param name that is not a {{hole}} name, a keyed param missing fallback, or neither titleKey nor bodyKey. This narrows nothing that existed: the member is new.
  3. Delivered text. With keyedText, each recipient's title and body are t(key, locale, params) from the registered i18n service. The locale is the recipient's, negotiated with resolveBundleLocale against getLocales(). Each member falls back to the literal payload.title or payload.body when it cannot be rendered: no i18n service, no renderer composed, t() echoing the key, an empty answer or a throw. A miss is said once per key and locale at warn. Without keyedText nothing is rendered and no locale is read.
  4. The delivery snapshot reserves keyedText. The snapshot already reserves title, body, severity, actionUrl and actorId, and now keyedText too. It carries EmitInput.keyedText only when the emit passed one. A producer payload member named keyedText is dropped from the snapshot and never rendered, so a flow notify node's free-form payload cannot reach the capability on one path only. This is the one edge where something a producer could previously put on the snapshot no longer rides it. Measured: no producer in the repository writes such a member (git grep keyedText outside these two packages: 0 hits).
  5. Unchanged. The event row (sys_notification.payload stores the producer's payload as before), recipients, preferences, availability, the notify template path and the sys_notification_template precedence, which now falls back to the rendered text. For an emit without keyedText, every channel's artifact is unchanged. That is pinned against a service with no renderer at all.

Public surface of @objectstack/service-messaging's . entry: plus NotificationTextRenderer, assertNotificationTextKeys and the types NotificationTextKeys, NotificationTextParam, NotificationTextI18n, NotificationTextItem, NotificationTextRendererOptions and RenderedNotificationText. Also new are the optional MessagingServiceContext.textRenderer, the optional NotificationDispatcherOptions.textRenderer and DeliveryPayload.keyedText. Nothing is removed or renamed.

@objectstack/plugin-approvals: no new export. The non-exported structural ApprovalMessagingSurface.emit input gains the optional keyedText (the mirror of item 1), and the private notify() now requires it. nameSubjectRecord keeps its name and returns the {{object}} {{record}} params instead of one string. No accept set of the plugin changes, so the changeset is a patch.

The catalog: one, and not a new one

  • Measured: the Operation Message Catalog (packages/spec/src/system/operation-message.ts). It is the spec-resident built-in catalog for operation-level refusals, addressed errors.ID. Its header says that filing anything else under that address "would give a deployment an override key that lies about what it overrides". A notification is not a refusal, and the spec is out of bounds here. It is not reused.
  • Reused: the i18n service's translation catalog, the one service-messaging's own translations already load into (MessagingTranslations, ADR-0029 D8, i18n.loadTranslations). A producer contributes its sentences in its own bundle's messages face. This is the shape @objectstack/plugin-audit already uses for its notification titles (messages.mentionedYou). The messages face is declared in the spec today, so the spec is not touched and no fork is needed. A deployment overrides a sentence with ordinary translation metadata (the authored layer wins on read).
  • Why the entries live in the plugin, not in messaging. The path is messaging's: one renderer, one locale chain, one read point. The sentences belong to the producer that owns them, as objects labels belong to the package that owns the object. A second emitter adds its own messages entries and passes keys, with no messaging change.

Channels, measured

Rendering happens ahead of the channel, so every channel receives the rendered title and body it already reads, including any channel a third party mounts.

Path Where it renders Pinned
inline fan-out (no outbox) MessagingService.fanOut, once per recipient for all of that recipient's channels inbox row in zh-CN, en and the deployment default
durable, single row NotificationDispatcher.processRow inbox, email (subject, text) and SMS (body) in zh-CN; the stored snapshot holds the keys plus the literal
durable, digest group NotificationDispatcher.processDigestGroup, one locale read per group each listed item in zh-CN
notify template path / sys_notification_template row unchanged precedence; the template row's fallback text is now the rendered text covered by the unchanged suites

The object label

The subject keeps the #23556 / #23594 ruling B form, the object label plus the id. Measured: nameSubjectRecord took the schema label, which no language pack localizes. It now rides as a keyed param, { key: objects.OBJECT.label, fallback: schema label }. That is the key reader-locale-labels.ts (#24977) and objectql's refusals name an object by. A zh-CN approver whose app ships objects.crm_deal.label reads 商机 D_1, and one without it reads the schema label.

Scope: the named six, and the five siblings fixed in place

The dispatch named approval.requested, approval.reminder, approval.sla_breached, approval.approved / approval.rejected and the out-of-office notices. Five siblings in the same file had the same defect: approval.escalated, approval.returned (send-back and auto-reject), approval.reassigned, approval.request_info and approval.comment. They are fixed here under the bounded in-place exemption, because all four conditions hold:

For request_info, comment and a send-back that carries a note, only the headline is keyed. The body is a person's own words and is delivered as written.

PM mechanism assumptions, measured on e4d604efd3

  • H1 holds, with one correction. The bodies were literals built at emit (approval.requested at approval-service.ts ~3913-3928 on e4d604efd3). Opening, reminders, the out-of-office notices and the decision outcome emit once per person. approval.sla_breached (approvers), approval.escalated and approval.comment emit ONE emit to several recipients. The delivery-time render covers both shapes, since each recipient gets their own locale either way.
  • H2 holds as a chain, not as a single read. recipient-locale.ts is the one chain function. The email and SMS channels read sys_user.locale on every delivery, with the address, and the inbox channel only on the notify template path. Rendering keyed text ahead of the channel reads it once more per keyed delivery, through the same function, so the two reads cannot disagree on a language.
  • H3 holds. A literal-only emit is byte-unchanged: the inbox row, mail and text are identical to a service composed without a renderer, the snapshot has no keyedText, and zero locale reads are made.

Tests

All readings below were taken on head 1408bfb197, which is this branch with origin/main fbb42d900f merged in. The dependency closures were rebuilt first.

  • New packages/services/service-messaging/src/keyed-notification-text.integration.test.ts (10 cases) runs a real ObjectQL over SqlDriver (better-sqlite3), the kernel's in-memory i18n, the real MessagingService, the inbox, email and SMS channels (fake transports), the SQL outbox and the dispatcher.
  • New packages/plugins/plugin-approvals/src/approval-notification-keyed-text.integration.test.ts (5 cases) runs the real ApprovalService over the same driver. Its battery reaches all 12 topics, and it renders through the real messaging renderer with this plugin's own bundle. End to end it uses a real MessagingService with the inbox channel.
  • Whole suites: @objectstack/service-messaging 55 files and 618 tests passed; @objectstack/plugin-approvals 93 files and 1208 tests passed. typecheck exits 0 in both packages, including approvals' check:test-typecheck. The 34 test files PR feat(driver-sql,driver-turso)!: 18.x step 7 part 7b-0 — a table the driver creates carries its tenant column NOT NULL #24940 edits are untouched.
  • plugin-approvals gains @objectstack/service-messaging as a devDependency, the shape service-automation already has, with a source alias in its vitest.config.ts (check:test-source-alias). The lockfile gains only that importer entry.

Ablation (one leg per pinned behaviour, on committed head cbff799d08). Each leg went through scripts/ablation-replace.mjs. The anchor hit exactly once, the mutation was proven on disk (anchor 1 to 0, blob changed) and the pin file was run. Each leg was then restored by git checkout HEAD -- PATH, proven by blob equality with HEAD:PATH and an empty git diff HEAD. A closing pass re-proved all five mutated files. Every subject resolves through source (relative imports, or the new ^@objectstack/service-messaging$ source alias), so no dist/ was involved.

Leg Mutation Red
L1 inline render renderedFor returns the unrendered notification 4 of 10 (zh-CN, deployment default, declared fallback, member-by-member)
L2 deployment default resolveRecipientLocale(own, undefined) 1 of 10 (no-locale recipient); approvals 1 of 5 (end to end)
L3 a miss falls back t() echoing the key is no longer a miss 5 of 10
L4 literal-only unchanged inline fan-out renders even without keys 1 of 10 (byte-unchanged, via the zero-locale-read pin)
L5 each site passes its key approvalNotice drops keyedText approvals 3 of 5
L6 durable render dispatcher skips the render 1 of 10 (inbox, email and SMS on the durable path)
L7 digest items digest rendered from the unrendered rows 1 of 10
L8 reserved snapshot name producer payload keyedText kept on the snapshot 1 of 10
L9 refused at emit the assert in emit() removed 1 of 10
L10 object label keyed the label param keyed to an absent key approvals 2 of 5

Gates

Local subset: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 84 families on 1408bfb197. All 84 ran sequentially under the verify lock, each exit captured before any pipe, and all 84 exited 0. --ran reconciles them: 84 derived, 84 run, 0 NOT-MEASURED, 0 UNRUN (a derived zero, since every family recorded its exit code).

  • An earlier union on the pre-merge head read check:query-options-erasure red: the test surface grew from 236 to 237 because a new test's counting proxy typed its findOne options any. The fix types it with Parameters of ObjectQL.findOne, and the ratchet holds at 236.
  • On that earlier head, check:i18n and check:dual-build-cjs-loads answered PREREQUISITE NOT MET (exit 3), which is not a measurement. Both were measured on the final head: check:i18n reports all bundles in 10 packages in sync, and check:dual-build-cjs-loads loads 112 entry points across 69 packages.
  • Outside the derived set: eslint --no-inline-config over the 11 changed source and test files exits 0, scripts/check-nul-bytes.mjs exits 0, and a control-byte scan of the changed files finds nothing.
  • CI-owned families (the repo-wide pnpm lint and the required contexts) are read from this PR's check-runs, not re-run here.

Acceptance notes

  • Finding, class a (filed by the seat, not here). A digest that collapses more than one notification is headed by the dispatcher's own English literal, You have N notifications (digest-render.ts), for every recipient. The items under it are rendered in the recipient's language after this PR. Reach: a recipient with a daily or weekly digest preference (sys_notification_preference.digest, written through the data API). Dedupe words: digest title English, renderDigest locale, You have notifications literal.
  • Observation, carrier: none. @objectstack/plugin-audit's collab.mention title is still rendered at emit, in the writer's resolved workspace locale (resolveWriteLocale), not per recipient at delivery. It could move to keyedText now that the path exists. That is not a defect this card owns.
  • Observation. The flow notify node's inline title is untouched and stays recipient-independent, as docs/qa/platform-checklist/areas/i18n.json pins.

Generated by Claude Code

…ered per recipient at delivery

Claude-Session: https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK
Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK
Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK
Co-authored-by: Claude <noreply@anthropic.com>
…e names the keyed-text reader

Claude-Session: https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/xl dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation tests tooling labels Oct 21, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/plugin-approvals, @objectstack/service-messaging, touching 140 documentable anchor(s). ⚠️ 4 changed file(s) yielded no anchor (packages/plugins/plugin-approvals/vitest.config.ts, packages/services/service-messaging/src/fan-out-system-context.ts, packages/services/service-messaging/src/index.ts, …), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

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

  • content/docs/api/client-sdk.mdx (via approvals.getRequest (sdk, the route ledger binds it to GET /api/v1/approvals/requests/:id, selected by route anchor /approvals/requests/:id), getRequest (sdk, the bare tail of client method approvals.getRequest, bound to GET /api/v1/approvals/requests/:id), meta.publishItem (sdk, the route ledger binds it to POST /api/v1/meta/:type/:name/publish, selected by route anchor /:type/:name/publish), publishItem (sdk, the bare tail of client method meta.publishItem, bound to POST /api/v1/meta/:type/:name/publish))
  • content/docs/api/metadata-api.mdx (via /:type/:name/publish (route, bridged from symbol getData — its route source's handler names it))
  • content/docs/api/plugin-endpoints.mdx (via /approvals/requests/:id (route, bridged from symbol requestInfo — its route source's handler names it; bridged from symbol sendBack — its route source's handler names it))
  • content/docs/automation/approvals.mdx (via getRequest (sdk, the bare tail of client method approvals.getRequest, bound to GET /api/v1/approvals/requests/:id), /approvals/requests/:id (route, bridged from symbol requestInfo — its route source's handler names it; bridged from symbol sendBack — its route source's handler names it))
  • content/docs/automation/flows.mdx (via ApprovalService (symbol, a top-level class), /approvals/requests/:id (route, bridged from symbol requestInfo — its route source's handler names it; bridged from symbol sendBack — its route source's handler names it))
  • content/docs/automation/webhooks.mdx (via MessagingServicePlugin (symbol, a top-level class), maxIdleIntervalMs (literal, a string literal in NotificationDispatcher), onAttempt (literal, a string literal in NotificationDispatcher; a string literal in Pick))
  • content/docs/kernel/contracts/metadata-service.mdx (via /:type/:name/publish (route, bridged from symbol getData — its route source's handler names it))
  • content/docs/kernel/services-checklist.mdx (via getData (symbol, a method of interface NotificationTextRendererOptions), getLocales (symbol, a method of interface NotificationTextI18n), /api/v1/i18n/locales (route, the route ledger binds it to client method i18n.getLocales), /approvals/requests/:id (route, bridged from symbol requestInfo — its route source's handler names it; bridged from symbol sendBack — its route source's handler names it))
  • content/docs/permissions/system-context.mdx (via requestInfo (symbol, a method of class ApprovalService), sendBack (symbol, a method of class ApprovalService))
  • content/docs/protocol/kernel/error-handling.mdx (via /:type/:name/publish (route, bridged from symbol getData — its route source's handler names it))
  • content/docs/protocol/kernel/http-protocol.mdx (via getDefaultLocale (symbol, a method of interface NotificationTextRendererOptions), getLocales (symbol, a method of interface NotificationTextI18n))

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

  • content/docs/releases/v14.mdx (via MessagingServicePlugin (symbol, a top-level class))
  • content/docs/releases/v16.mdx (via getRequest (sdk, the bare tail of client method approvals.getRequest, bound to GET /api/v1/approvals/requests/:id))
  • content/docs/releases/v17/17-0.mdx (via getRequest (sdk, the bare tail of client method approvals.getRequest, bound to GET /api/v1/approvals/requests/:id), /:type/:name/publish (route, bridged from symbol getData — its route source's handler names it))
  • content/docs/releases/v17/17-1.mdx (via /:type/:name/publish (route, bridged from symbol getData — its route source's handler names it))
  • content/docs/releases/v17/17-2.mdx (via MessagingService (symbol, a top-level class), /:type/:name/publish (route, bridged from symbol getData — its route source's handler names it))
  • content/docs/releases/v17/17-4.mdx (via /:type/:name/publish (route, bridged from symbol getData — its route source's handler names it))
  • content/docs/releases/v17/17-5.mdx (via MessagingServicePlugin (symbol, a top-level class), NotificationDispatcher (symbol, a top-level class), maxIdleIntervalMs (literal, a string literal in NotificationDispatcher))
  • content/docs/releases/v17/17-6.mdx (via ApprovalService (symbol, a top-level class))
  • content/docs/releases/v18/18-0.mdx (via /api/v1/i18n/locales (route, the route ledger binds it to client method i18n.getLocales))

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
  • 4 changed file(s) yielded no anchor (packages/plugins/plugin-approvals/vitest.config.ts, packages/services/service-messaging/src/fan-out-system-context.ts, packages/services/service-messaging/src/index.ts, …) — pages documenting those are invisible to this run
  • 20 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 — 7 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 93e6c1a5f8bf256310f9ed477523ff2c90bf1bff → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 93e6c1a5f8bf256310f9ed477523ff2c90bf1bff

⚠️ 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 93e6c1a5f8bf256310f9ed477523ff2c90bf1bff → 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-21T00:58Z

os-justin · session_011TdZ8GjixNZFLz1xJD9zzK (seat post #6021). Reviewed against GitHub and origin/main 62bc129308, not against the report (os-dev report 6305012957 on #24978).

Head under review:

1408bfb197cf80b2d3dea541d14c9b9f2efdbe65
  1. Shape.
    • Draft, base main, first line Fixes #24978. Line-start Clause-②: yes (widening) appears in the body and in the service-messaging changeset.
    • 19 files, +1821/−94, none on a governed surface (governedPathsIn answers []). The trial merge onto origin/main is clean.
    • The review face is declaration at TIER_DEFAULT (no schema, no governed path, no narrowing declared), so this ACCEPT is the review of record of the widening, read below sentence by sentence.
    • packages/spec is untouched. PR feat(driver-sql,driver-turso)!: 18.x step 7 part 7b-0 — a table the driver creates carries its tenant column NOT NULL #24940's 34 plugin-approvals test files are untouched; the new pins are new files.
  2. The contract, read in the code against the PR body's five sentences.
    • ① Widened. EmitInput.keyedText? is optional and additive. Every input accepted before is still accepted. True in messaging-service.ts: the member is read only when present.
    • ② Closed shape, refused at emit. assertNotificationTextKeys runs first in emit(), before the dedup read and before any write. It refuses an undeclared member, a key with no face, a param name no hole can spell, a keyed param without fallback, and the absence of both keys. It narrows nothing that existed. A key that could never render is a loud error, not a literal delivered forever, which is the frame's AI-authoring axis.
    • ③ Delivered text.
    • ④ The snapshot reserves keyedText. Read as part of the widening; the open question is ruled below.
    • ⑤ Unchanged. The event row, recipients, preferences, availability and the template path. A literal-only emit is pinned byte-unchanged against a service with no renderer.
  3. The catalog.
    • It is the i18n service's existing catalog, in the declared messages face, the shape plugin-audit's messages.mentionedYou already uses. ⛔ There is no second catalog.
    • The Operation Message Catalog (errors.ID, refusals) was measured and correctly not reused.
    • Read against the spec's retired object-first notifications key (translation.zod.ts:605, "notifications have no translation group"): that guidance retires a top-level authoring group, and this PR adds no such group and no spec key. Producer sentences live in the producer's messages face.
  4. The emitters.
    • All 14 notify() sites in approval-service.ts pass keys. The en entries interpolated are byte-identical to the previous literals (pinned).
    • The subject keeps ruling B's "object label + id" form, with the label as a keyed param (objects.<object>.label, falling back to the schema label).
    • A person's own words (a comment, a request for information, a send-back note) stay as written; only their headlines are keyed.
    • Five sibling notifications beyond the card's named six are converted under the bounded in-place rule. They are named in the body, all four conditions hold, and the seat accepts them.
  5. Pins and ablation.
    • service-messaging: keyed-notification-text.integration.test.ts, 10 cases. They cover the inline, durable and digest paths, a zh-CN recipient, bare zh, the deployment default, a miss falling back, a literal-only emit unchanged, the reserved snapshot name, and refusal at emit.
    • plugin-approvals: approval-notification-keyed-text.integration.test.ts, 5 cases. They cover every topic carrying its key, locale parity, and end to end with a real MessagingService and inbox.
    • Ablation: 10 legs, each red on its pin. Every restore was proven by blob equality and an empty git diff HEAD.
  6. Changesets. @objectstack/service-messaging minor (rule 「甲」: an 18.x minor) and @objectstack/plugin-approvals patch (no accept set or export of the plugin moves). Each was read sentence by sentence, and each matches 2–4.
  7. The open question, ruled by the seat: A, keep the reservation as shipped.
    • Real need: no producer writes payload.keyedText (measured: 0 hits outside the two packages).
    • Long-term: the snapshot keeps one owner for its reserved names, the rule it already applies to title, body, severity, actionUrl and actorId.
    • AI authoring: the axis splits. C (refusal at emit) is the louder answer, while A drops a same-named producer member silently. A still makes keyed rendering reachable only through the typed, validated member, and it spends no accept-set narrowing on a name nobody writes.
    • Startup scope: A adds no surface and no gate.
    • The maintainer may veto toward C. That would be a separate narrowing card, and nothing in this PR forecloses it.
  8. Gates.
    • At ACCEPT, every check-run on this head is success or an expected skip. Among them are TypeScript Type Check, the Lint lanes, Test Core 1–6/6, Build Core, Validate Package Dependencies, Check Changeset, Governed Surface Queue Guard and Check Documentation Links.
    • Locally, all 84 derived families exited 0, and --ran reads 0 NOT MEASURED.
    • service-messaging: 618 tests passed. plugin-approvals: 1208 tests passed.
  9. Accepted deviations.
    • One merge of main.
    • The lockfile carries only the 3-line plugin-approvals importer entry, where pnpm install had reshuffled two unrelated peers. Those hunks were discarded, and --frozen-lockfile exits 0.
    • A service-messaging devDependency and an anchored source alias in plugin-approvals' vitest.config.ts (check:test-source-alias green).
    • WIP commit subjects, which the squash replaces.
    • A long hold of the verify lock, flagged by the lock itself.
    • Model-free trailers.
  10. Out-of-scope disposition.
  1. 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

dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

2 participants