@@ -22,6 +22,17 @@ export interface MessagingEmitSurface {
2222 } ) : Promise < unknown > ;
2323}
2424
25+ /**
26+ * Minimal structural view of `II18nService.t`. Declared locally (same
27+ * rationale as {@link MessagingEmitSurface}) so plugin-audit resolves whatever
28+ * object is registered under the `i18n` service without a runtime dependency
29+ * on service-i18n. The kernel always registers at least the in-memory
30+ * fallback, and `t` returns the key verbatim on a miss.
31+ */
32+ export interface AuditI18nSurface {
33+ t ( key : string , locale : string , params ?: Record < string , unknown > ) : string ;
34+ }
35+
2536/** Options for {@link installAuditWriters}. */
2637export interface AuditWriterOptions {
2738 /**
@@ -32,6 +43,19 @@ export interface AuditWriterOptions {
3243 * matching the `notify` node's degradation.
3344 */
3445 getMessaging ?( ) : MessagingEmitSurface | undefined ;
46+ /**
47+ * Lazily resolve the i18n service used to localize activity summaries
48+ * (framework#3039): verb templates (`messages.activityCreated` …) and object
49+ * display labels (`objects.{name}.label`). Absent → English summaries.
50+ */
51+ getI18n ?( ) : AuditI18nSurface | undefined ;
52+ /**
53+ * Resolve the workspace default locale (ADR-0053 `localization.locale`) for
54+ * the write's tenant/user scope. Called per audited write but memoized here
55+ * with a short TTL, so implementations may hit the settings service
56+ * directly. Absent → summaries stay English (status quo).
57+ */
58+ getLocale ?( tenantId ?: string , userId ?: string ) : Promise < string | undefined > ;
3559}
3660
3761/**
@@ -244,6 +268,28 @@ export function installAuditWriters(
244268 if ( ! engine || typeof engine . registerHook !== 'function' ) return ;
245269
246270 const getMessaging = opts . getMessaging ?? ( ( ) => undefined ) ;
271+ const getI18n = opts . getI18n ?? ( ( ) => undefined ) ;
272+
273+ // Workspace locale changes rarely, but writeAudit runs on every CRUD write —
274+ // memoize the settings lookup per principal scope with a short TTL so audit
275+ // logging doesn't add a settings query to every mutation's hot path.
276+ const LOCALE_TTL_MS = 30_000 ;
277+ const localeCache = new Map < string , { value : string | undefined ; expires : number } > ( ) ;
278+ const resolveWriteLocale = async ( tenantId ?: string , userId ?: string ) : Promise < string | undefined > => {
279+ if ( ! opts . getLocale ) return undefined ;
280+ const cacheKey = `${ tenantId ?? '' } |${ userId ?? '' } ` ;
281+ const now = Date . now ( ) ;
282+ const hit = localeCache . get ( cacheKey ) ;
283+ if ( hit && hit . expires > now ) return hit . value ;
284+ let value : string | undefined ;
285+ try {
286+ value = await opts . getLocale ( tenantId , userId ) ;
287+ } catch {
288+ value = undefined ;
289+ }
290+ localeCache . set ( cacheKey , { value, expires : now + LOCALE_TTL_MS } ) ;
291+ return value ;
292+ } ;
247293
248294 // Remove any prior installation so we can safely re-install on hot reload.
249295 if ( typeof engine . unregisterHooksByPackage === 'function' ) {
@@ -449,19 +495,39 @@ export function installAuditWriters(
449495 const label = recordLabel ( after ?? before , recordId ?? '' ) ;
450496 // Summaries are user-facing (the record Discussion feed and Setup
451497 // dashboards render them verbatim), so name the object by its display
452- // label ("Semantic Zoo"), not its API name ("showcase_semantic_zoo").
453- // Best-effort: falls back to the API name when the def isn't resolvable.
498+ // label ("Semantic Zoo"), not its API name ("showcase_semantic_zoo"), and
499+ // localize both the verb template and the object label to the workspace
500+ // default locale (ADR-0053, framework#3039). Every step is best-effort:
501+ // no locale / no i18n / key miss all degrade to the English literal.
502+ const locale = await resolveWriteLocale ( tenantId , userId ) ;
503+ const translate = ( key : string , params ?: Record < string , unknown > ) : string | undefined => {
504+ if ( ! locale ) return undefined ;
505+ const i18n = getI18n ( ) ;
506+ if ( ! i18n || typeof i18n . t !== 'function' ) return undefined ;
507+ try {
508+ const value = i18n . t ( key , locale , params ) ;
509+ // A miss returns the key verbatim (II18nService contract).
510+ return typeof value === 'string' && value !== key ? value : undefined ;
511+ } catch {
512+ return undefined ;
513+ }
514+ } ;
454515 const objectDef = getObjectDef ( ctx . object ) ;
455516 const objectDisplay =
456- typeof objectDef ?. label === 'string' && objectDef . label . length > 0
517+ translate ( `objects.${ ctx . object } .label` ) ??
518+ ( typeof objectDef ?. label === 'string' && objectDef . label . length > 0
457519 ? objectDef . label
458- : ctx . object ;
520+ : ctx . object ) ;
459521 let summary : string ;
460522 let activityType : string = activityTypeFor ( action ) ;
461523 if ( action === 'create' ) {
462- summary = `Created ${ objectDisplay } "${ label } "` ;
524+ summary =
525+ translate ( 'messages.activityCreated' , { object : objectDisplay , label } ) ??
526+ `Created ${ objectDisplay } "${ label } "` ;
463527 } else if ( action === 'delete' ) {
464- summary = `Deleted ${ objectDisplay } "${ label } "` ;
528+ summary =
529+ translate ( 'messages.activityDeleted' , { object : objectDisplay , label } ) ??
530+ `Deleted ${ objectDisplay } "${ label } "` ;
465531 } else {
466532 // ADR-0052 §5b — declarative activity, precedence: a configured semantic
467533 // milestone (§5b.2) wins; else a tracked field-change diff ("Stage:
@@ -473,6 +539,7 @@ export function installAuditWriters(
473539 } else {
474540 summary =
475541 renderTrackedChangeSummary ( getFieldDefs ( ctx . object ) , oldValue , newValue ) ??
542+ translate ( 'messages.activityUpdated' , { object : objectDisplay , label } ) ??
476543 `Updated ${ objectDisplay } "${ label } "` ;
477544 }
478545 }
0 commit comments