@cosmicdrift/kumiko-framework 0.321.0 → 0.322.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.321.0",
3
+ "version": "0.322.0",
4
4
  "description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -202,8 +202,8 @@
202
202
  "./package.json": "./package.json"
203
203
  },
204
204
  "dependencies": {
205
- "@cosmicdrift/kumiko-http": "0.321.0",
206
- "@cosmicdrift/kumiko-types": "0.321.0",
205
+ "@cosmicdrift/kumiko-http": "0.322.0",
206
+ "@cosmicdrift/kumiko-types": "0.322.0",
207
207
  "bullmq": "^5.76.7",
208
208
  "bun-types": "^1.3.13",
209
209
  "hono": "^4.13.1",
@@ -219,7 +219,7 @@
219
219
  "zod": "^4.4.3"
220
220
  },
221
221
  "devDependencies": {
222
- "@cosmicdrift/kumiko-dispatcher-live": "0.321.0",
222
+ "@cosmicdrift/kumiko-dispatcher-live": "0.322.0",
223
223
  "bun-types": "^1.3.13",
224
224
  "pino-pretty": "^13.1.3"
225
225
  },
package/src/api/index.ts CHANGED
@@ -75,7 +75,11 @@ export type {
75
75
  export { isSafeLandingPath } from "./post-auth-landing";
76
76
  export type { RedisSseBroker, RedisSseBrokerOptions } from "./redis-sse-broker";
77
77
  export { createDefaultSseBroker, createRedisSseBroker, isRedisSseBroker } from "./redis-sse-broker";
78
- export { type RequestContextData, requestContext } from "./request-context";
78
+ export {
79
+ type RequestContextData,
80
+ requestContext,
81
+ runAsDirectCallEntry,
82
+ } from "./request-context";
79
83
  export {
80
84
  buildRequestContextDataFromRequest,
81
85
  requestIdMiddleware,
@@ -1,5 +1,6 @@
1
1
  import { AsyncLocalStorage } from "node:async_hooks";
2
2
  import { isPersonalDataGated, type WriteOrigin } from "@cosmicdrift/kumiko-types/event-store-types";
3
+ import type { AgentRisk } from "@cosmicdrift/kumiko-types/handlers";
3
4
  import { generateId } from "../utils";
4
5
 
5
6
  // Request-scoped propagation. Populated by the HTTP middleware and by the
@@ -54,6 +55,10 @@ export type RequestContextData = {
54
55
  // Only ever a gated origin. Read by job enqueue and event-store.append();
55
56
  // dispatch roots never narrow from it, only jobs inherit explicitly.
56
57
  readonly writeOrigin?: WriteOrigin;
58
+ // The handler directly invoked by the dispatcher (not a nested ctx.write/
59
+ // writeAs target) — nested dispatches inherit it unchanged, so the
60
+ // irreversible-operation gate always sees the outermost caller's risk.
61
+ readonly entryHandler?: { readonly qn: string; readonly risk: AgentRisk };
57
62
  };
58
63
 
59
64
  const storage = new AsyncLocalStorage<RequestContextData>();
@@ -77,7 +82,11 @@ export const requestContext = {
77
82
  // when there is no surrounding request (job-runner, event-dispatcher) —
78
83
  // requestId/correlationId are mandatory, `get()` may be undefined.
79
84
  export function runWithOrigin<T>(
80
- origin: { readonly feature?: string; readonly handler?: string },
85
+ origin: {
86
+ readonly feature?: string;
87
+ readonly handler?: string;
88
+ readonly entryHandler?: { readonly qn: string; readonly risk: AgentRisk };
89
+ },
81
90
  fn: () => T,
82
91
  ): T {
83
92
  const current = requestContext.get();
@@ -89,11 +98,24 @@ export function runWithOrigin<T>(
89
98
  correlationId: current?.correlationId ?? requestId,
90
99
  feature: origin.feature,
91
100
  handler: origin.handler,
101
+ // The outermost dispatch wins — a nested ctx.write/writeAs must not
102
+ // overwrite the entry handler that the irreversible-operation gate reads.
103
+ entryHandler: current?.entryHandler ?? origin.entryHandler,
92
104
  },
93
105
  fn,
94
106
  );
95
107
  }
96
108
 
109
+ // Clears entryHandler so the next dispatch inside fn becomes the new entry —
110
+ // used by dispatchToolCall so the invoked tool handler, not the surrounding
111
+ // turn/approve handler, is what the irreversible-operation gate sees.
112
+ export function runAsDirectCallEntry<T>(fn: () => T): T {
113
+ const current = requestContext.get();
114
+ if (!current) return fn();
115
+ const { entryHandler: _entryHandler, ...rest } = current;
116
+ return requestContext.run(rest, fn);
117
+ }
118
+
97
119
  // An ungated origin never mints a scope: seeds and boot writes keep their missing context.
98
120
  export function runWithWriteOrigin<T>(origin: WriteOrigin, fn: () => T): T {
99
121
  const current = requestContext.get();
package/src/changes.json CHANGED
@@ -1,4 +1,16 @@
1
1
  [
2
+ {
3
+ "version": "0.322.0",
4
+ "type": "breaking",
5
+ "title": "Irreversible writes now require agent.risk \"high\" on the directly-dispatched handler",
6
+ "migration": "Any write handler reachable as the directly-dispatched entry point that\nperforms `executor.forget()` (any entity) or `executor.delete()` on an\nentity without `softDelete` must resolve `agent.risk: \"high\"`, or the\nexecutor gate now denies it with `access_denied` /\n`irreversible_operation_requires_high_risk` before any DB read.\n\nThree breaking changes ship together:\n1. Add `agent: { risk: \"high\" }` to your own write handler if it directly\n runs `forget` or a hard `delete` on a non-softDelete entity.\n2. Delegating via `ctx.write`/`ctx.writeAs` from a lower-risk handler into\n a high-risk forget/hard-delete handler no longer inherits the inner\n handler's risk — the outermost/entry handler's declared risk governs,\n so the delegating handler itself must be \"high\".\n3. Declaring `agent.risk` below \"high\" on a standard entity-convention\n delete handler for a non-softDelete entity is now a define-time/boot\n error instead of a silently-accepted setting.\n\nStandard entity-convention delete handlers on a non-softDelete entity now\ndefault to \"high\" automatically (no more implicit \"always allow\").\n\n`dispatchToolCall` treats the invoked tool itself as the entry handler,\nnot the surrounding turn/approve write handler that dispatched it — a\nmid-risk \"approve\" handler can safely invoke a high-risk tool directly,\nbut a mid-risk tool that itself delegates to a high-risk handler via\n`ctx.write`/`writeAs` is still denied per rule 2.\n\n`runAsDirectCallEntry` (framework `/api`) is the one sanctioned entry\nboundary: it clears the entry handler so the next dispatch counts as the\ndirectly called one. `dispatchToolCall` uses it because the human\nconfirmation happens in approve; do not use it to lift a handler past\nthe floor.\n\nRemaining gap: `afterCommit` hooks, jobs, and event consumers are not\ngated by this floor — they have no directly-dispatched entry handler to\nattribute risk to."
7
+ },
8
+ {
9
+ "version": "0.322.0",
10
+ "type": "improvement",
11
+ "title": "Translation values may now be CLDR plural-form objects, resolved per locale with Intl.PluralRules",
12
+ "migration": "A translation entry's value type widens from `string` to\n`string | PluralForms`, so an existing `TranslationEntry` locale value can\nnow also be an object with `one`/`few`/`many`/`other` (etc.) CLDR\ncategories, where `other` is required:\n\n```ts\n{ de: { one: \"{count} Status-Seite\", other: \"{count} Status-Seiten\" } }\n```\n\n`createI18n(...).t(key, locale, { count })` resolves the CLDR category for\nthe given locale via a per-locale-cached `Intl.PluralRules` and interpolates\n`{count}`; it falls back to `other` when `count` is missing/non-finite, the\nlocale tag is invalid, or `Intl.PluralRules` is unavailable (older Hermes).\n`mailT` and the renderer's `translateWithFallbacks` resolve plural values\nthe same way, through the shared `resolveTranslationValue` helper.\n\nExisting string-only translations keep working unchanged. Code that reads\nvalues back out of a bundle (`TranslationBundle`, `TranslationsByLocale`,\n`TranslationEntry`) and annotates them as `string` no longer typechecks:\nannotate as `TranslationValue` (from `@cosmicdrift/kumiko-framework/ui-types`)\nor flatten with `translationValueOtherText(value)`."
13
+ },
2
14
  {
3
15
  "version": "0.321.0",
4
16
  "type": "improvement",
@@ -18,6 +18,10 @@ import {
18
18
  VersionConflictError as EventStoreVersionConflict,
19
19
  getStreamVersion,
20
20
  } from "../event-store";
21
+ import {
22
+ assertIrreversibleOperationAllowed,
23
+ isIrreversibleEntityVerb,
24
+ } from "../pipeline/irreversible-operation-gate";
21
25
  import { generateId } from "../utils";
22
26
  import { applyEntityEvent } from "./apply-entity-event";
23
27
  import { flattenCompoundTypes, rehydrateCompoundTypes } from "./compound-types";
@@ -586,6 +590,9 @@ export function createWriteVerbs(
586
590
  },
587
591
 
588
592
  async delete(payload, user, db) {
593
+ if (isIrreversibleEntityVerb("delete", entity)) {
594
+ assertIrreversibleOperationAllowed(`${entityName}.delete (entity has no softDelete)`);
595
+ }
589
596
  const runner = tenantDbRunner(db);
590
597
  const existing = await loadById(payload.id, db);
591
598
  if (!existing) return writeFailure(new NotFoundError(entityName, payload.id));
@@ -684,6 +691,7 @@ export function createWriteVerbs(
684
691
  // the erasure replays on rebuild (created → forgotten → row gone). Loads
685
692
  // without the isDeleted filter so trashed (soft-deleted) rows are erased too.
686
693
  async forget(payload, user, db) {
694
+ assertIrreversibleOperationAllowed(`${entityName}.forget`);
687
695
  const runner = tenantDbRunner(db);
688
696
  const raw = await db.fetchOne<Record<string, unknown>>(table, { id: payload.id });
689
697
  if (!raw) return writeFailure(new NotFoundError(entityName, payload.id));
@@ -110,6 +110,34 @@ describe("defineEntityWriteHandler", () => {
110
110
  // @ts-expect-error access is required since fw#2855 — no implicit openToAll.
111
111
  expect(() => defineEntityCreateHandler("note", noteEntity)).toThrow();
112
112
  });
113
+
114
+ test("delete on an entity without softDelete defaults agent.risk to high", () => {
115
+ const def = defineEntityDeleteHandler("note", noteEntity, adminAccess);
116
+ expect(resolveAgentExposure(def, "write").risk).toBe("high");
117
+ });
118
+
119
+ test("delete on an entity without softDelete preserves other agent hints while forcing risk high", () => {
120
+ const def = defineEntityDeleteHandler("note", noteEntity, {
121
+ access: { roles: ["Admin"] },
122
+ agent: { expose: false },
123
+ });
124
+ expect(def.agent).toEqual({ expose: false, risk: "high" });
125
+ });
126
+
127
+ test("delete on an entity without softDelete throws when agent.risk is explicitly set below high", () => {
128
+ expect(() =>
129
+ defineEntityDeleteHandler("note", noteEntity, {
130
+ access: { roles: ["Admin"] },
131
+ agent: { risk: "mid" },
132
+ }),
133
+ ).toThrow(/agent\.risk must be "high"/);
134
+ });
135
+
136
+ test("delete on a softDelete entity leaves agent hints untouched", () => {
137
+ const def = defineEntityDeleteHandler("note", noteEntitySoftDelete, adminAccess);
138
+ expect(def.agent).toBeUndefined();
139
+ expect(resolveAgentExposure(def, "write").risk).toBe("mid");
140
+ });
113
141
  });
114
142
 
115
143
  describe("defineEntityQueryHandler", () => {
@@ -16,6 +16,7 @@ import { PAGED_QUERY_HANDLER_BRAND } from "./define-handler";
16
16
  import { buildInsertSchema, buildUpdateSchema } from "./schema-builder";
17
17
  import type {
18
18
  AccessRule,
19
+ AgentHandlerHints,
19
20
  DeriveContext,
20
21
  EntityDefinition,
21
22
  EscapeHatchDeclaration,
@@ -213,6 +214,23 @@ function isInTotalsMatchPair(entity: EntityDefinition, fieldName: string): boole
213
214
  );
214
215
  }
215
216
 
217
+ // delete on an entity without softDelete hard-purges the row — the agent
218
+ // risk gate only protects it if this handler resolves to "high".
219
+ function resolveEntityWriteAgentHints(
220
+ name: string,
221
+ verb: (typeof WRITE_VERBS)[number],
222
+ entity: EntityDefinition,
223
+ agent: AgentHandlerHints | undefined,
224
+ ): AgentHandlerHints | undefined {
225
+ if (verb !== "delete" || entity.softDelete) return agent;
226
+ if (agent?.risk !== undefined && agent.risk !== "high") {
227
+ throw new Error(
228
+ `"${name}": delete on an entity without softDelete is irreversible — agent.risk must be "high" (got "${agent.risk}").`,
229
+ );
230
+ }
231
+ return { ...agent, risk: "high" };
232
+ }
233
+
216
234
  function assertExcludableFields(
217
235
  name: string,
218
236
  verb: (typeof WRITE_VERBS)[number],
@@ -253,6 +271,7 @@ export function defineEntityWriteHandler(
253
271
  `"${name}": restore is only valid for entities declared with softDelete: true.`,
254
272
  );
255
273
  }
274
+ const agentHints = resolveEntityWriteAgentHints(name, verb, entity, options.agent);
256
275
  const excludedFields = options.excludeFields ?? [];
257
276
  assertExcludableFields(name, verb, entity, excludedFields);
258
277
 
@@ -378,7 +397,7 @@ export function defineEntityWriteHandler(
378
397
  handler,
379
398
  access: options.access,
380
399
  ...(options.description !== undefined && { description: options.description }),
381
- ...(options.agent !== undefined && { agent: options.agent }),
400
+ ...(agentHints !== undefined && { agent: agentHints }),
382
401
  };
383
402
  }
384
403
 
@@ -479,6 +479,7 @@ export type {
479
479
  PersonalAnnotationsNoFind,
480
480
  PersonalSubject,
481
481
  PlatformComponent,
482
+ PluralForms,
482
483
  PostDeleteHookFn,
483
484
  PostSaveHookFn,
484
485
  PreDeleteHookFn,
@@ -528,8 +529,10 @@ export type {
528
529
  TenantId,
529
530
  TextFieldDef,
530
531
  ToolbarAction,
532
+ TranslationEntry,
531
533
  TranslationKeys,
532
534
  TranslationsDef,
535
+ TranslationValue,
533
536
  TreeAction,
534
537
  TreeActionDef,
535
538
  TreeActionsHandle,
@@ -38,6 +38,7 @@ import type {
38
38
  SecretKeyDefinition,
39
39
  StoreTableDef,
40
40
  StreamHandlerDef,
41
+ TranslationValue,
41
42
  TreeActionDef,
42
43
  WorkspaceDefinition,
43
44
  WriteHandlerDef,
@@ -212,7 +213,7 @@ export type RegistryState = {
212
213
  extensionSelectorMap: Map<string, string>;
213
214
  allReferenceData: ReferenceDataDef[];
214
215
  allConfigSeeds: ConfigSeedDef[];
215
- mergedTranslations: Record<string, Record<string, string>>;
216
+ mergedTranslations: Record<string, Record<string, TranslationValue>>;
216
217
  metricMap: Map<string, FeatureMetricDef & { readonly featureName: string }>;
217
218
  secretKeyMap: Map<string, SecretKeyDefinition>;
218
219
  projectionMap: Map<string, ProjectionDefinition>;
@@ -42,6 +42,7 @@ export type {
42
42
  NotificationDefinition,
43
43
  NotificationRecipientFn,
44
44
  NotificationTemplateFn,
45
+ PluralForms,
45
46
  ReferenceDataDef,
46
47
  RegistrarExtensionDef,
47
48
  RegistrarExtensionHooks,
@@ -50,6 +51,7 @@ export type {
50
51
  TranslationEntry,
51
52
  TranslationKeys,
52
53
  TranslationsDef,
54
+ TranslationValue,
53
55
  UiExtensionDef,
54
56
  } from "@cosmicdrift/kumiko-types/config";
55
57
  export type {
@@ -159,6 +159,20 @@ public_intake_required:
159
159
  Wurzel-Handler `access: { roles: [..., "anonymous"], personalData: "public-intake" }`
160
160
  deklarieren.
161
161
 
162
+ irreversible_operation_requires_high_risk:
163
+ endUser: |
164
+ Diese Aktion ist nicht erlaubt.
165
+ Wende dich an einen Administrator wenn du glaubst, dass das ein Fehler ist.
166
+ developer: |
167
+ `AccessDeniedError`: der direkt aufgerufene Einstiegs-Handler führt ein
168
+ hartes Delete oder `forget` aus (`details.operation`), aber sein
169
+ aufgelöstes Agent-Risiko (`details.resolvedRisk`) liegt unter `"high"`.
170
+ `details.handler` nennt den Einstiegs-Handler — eine Delegation über
171
+ `ctx.write`/`writeAs` aus einem Handler mit niedrigerem Risiko erbt das
172
+ deklarierte Risiko des inneren Handlers nicht.
173
+
174
+ `agent: { risk: "high" }` am genannten Handler deklarieren.
175
+
162
176
  agent.tool_not_allowed:
163
177
  endUser: |
164
178
  Dieses Tool ist im aktuellen Modus nicht verfügbar.
@@ -153,6 +153,19 @@ public_intake_required:
153
153
  `access: { roles: [..., "anonymous"], personalData: "public-intake" }` on
154
154
  the root handler if the anonymous intake is intended.
155
155
 
156
+ irreversible_operation_requires_high_risk:
157
+ endUser: |
158
+ This action isn't allowed.
159
+ Please contact an administrator if you believe this is a mistake.
160
+ developer: |
161
+ `AccessDeniedError`: the directly-dispatched entry handler performs a hard
162
+ delete or `forget` (`details.operation`) but its resolved agent risk
163
+ (`details.resolvedRisk`) is below `"high"`. `details.handler` names the
164
+ entry handler — delegating the call via `ctx.write`/`writeAs` from a
165
+ lower-risk handler does not inherit an inner handler's declared risk.
166
+
167
+ Declare `agent: { risk: "high" }` on the named handler.
168
+
156
169
  agent.tool_not_allowed:
157
170
  endUser: |
158
171
  This tool is not available in the current mode.
@@ -63,6 +63,11 @@ export const FrameworkReasons = {
63
63
  // AccessDeniedError: a write under an anonymous root touched a personal-data field
64
64
  // without the root handler declaring access.personalData: "public-intake".
65
65
  publicIntakeRequired: "public_intake_required",
66
+
67
+ // AccessDeniedError: the directly-dispatched entry handler performs a hard
68
+ // delete/forget but its agent.risk resolves below "high" — delegation via
69
+ // ctx.write/writeAs from a lower-risk handler does not inherit the gate.
70
+ irreversibleOperationRequiresHighRisk: "irreversible_operation_requires_high_risk",
66
71
  } as const;
67
72
 
68
73
  export type FrameworkReason = (typeof FrameworkReasons)[keyof typeof FrameworkReasons];
@@ -76,3 +76,38 @@ describe("createI18n", () => {
76
76
  expect(keys).toContain("adminUsers:field.email");
77
77
  });
78
78
  });
79
+
80
+ describe("createI18n — plural forms", () => {
81
+ const notificationsFeature = defineFeature("notifications", (r) => {
82
+ r.translations({
83
+ keys: {
84
+ "notifications.unread": {
85
+ de: { one: "{count} ungelesene Nachricht", other: "{count} ungelesene Nachrichten" },
86
+ en: { one: "{count} unread message", other: "{count} unread messages" },
87
+ },
88
+ },
89
+ });
90
+ });
91
+
92
+ test("resolves the CLDR category for the given locale and count", () => {
93
+ const registry = createRegistry([notificationsFeature]);
94
+ const i18n = createI18n(registry, { defaultLocale: "en" });
95
+
96
+ expect(i18n.t("notifications:notifications.unread", "en", { count: 1 })).toBe(
97
+ "1 unread message",
98
+ );
99
+ expect(i18n.t("notifications:notifications.unread", "en", { count: 5 })).toBe(
100
+ "5 unread messages",
101
+ );
102
+ expect(i18n.t("notifications:notifications.unread", "de", { count: 1 })).toBe(
103
+ "1 ungelesene Nachricht",
104
+ );
105
+ });
106
+
107
+ test("falls back to `other` when no params are given", () => {
108
+ const registry = createRegistry([notificationsFeature]);
109
+ const i18n = createI18n(registry, { defaultLocale: "en" });
110
+
111
+ expect(i18n.t("notifications:notifications.unread", "en")).toBe("{count} unread messages");
112
+ });
113
+ });
@@ -33,3 +33,22 @@ test("resolveMailLocale prefers exact, then root, then en", () => {
33
33
  expect(resolveMailLocale("de-AT")).toBe("de");
34
34
  expect(resolveMailLocale("fr-CA")).toBe("en");
35
35
  });
36
+
37
+ describe("mailT — plural forms", () => {
38
+ registerMailTranslations("en", {
39
+ "test.unread": { one: "{count} unread message", other: "{count} unread messages" },
40
+ });
41
+ registerMailTranslations("de", {
42
+ "test.unread": { one: "{count} ungelesene Nachricht", other: "{count} ungelesene Nachrichten" },
43
+ });
44
+
45
+ test("resolves the CLDR category for the count", () => {
46
+ expect(mailT("en", "test.unread", { count: 1 })).toBe("1 unread message");
47
+ expect(mailT("en", "test.unread", { count: 5 })).toBe("5 unread messages");
48
+ expect(mailT("de", "test.unread", { count: 1 })).toBe("1 ungelesene Nachricht");
49
+ });
50
+
51
+ test("uses the fallback locale's own plural rules, not the requested locale's", () => {
52
+ expect(mailT("fr", "test.unread", { count: 1 })).toBe("1 unread message");
53
+ });
54
+ });
package/src/i18n/index.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { Registry, TranslationKeys } from "../engine/types";
2
+ import { resolveTranslationValue } from "../ui-types/plural";
2
3
 
3
4
  export {
4
5
  hasMailTranslations,
@@ -20,7 +21,7 @@ export type I18nOptions = {
20
21
  };
21
22
 
22
23
  export type I18n = {
23
- t(key: string, locale?: string): string;
24
+ t(key: string, locale?: string, params?: Readonly<Record<string, unknown>>): string;
24
25
  getAllKeys(): string[];
25
26
  };
26
27
 
@@ -29,12 +30,18 @@ export function createI18n(registry: Registry, options: I18nOptions): I18n {
29
30
  const { defaultLocale } = options;
30
31
 
31
32
  return {
32
- t(key: string, locale?: string): string {
33
+ t(key: string, locale?: string, params?: Readonly<Record<string, unknown>>): string {
33
34
  const entry = translations[key];
34
35
  if (!entry) return key;
35
36
 
36
- const resolvedLocale = locale ?? defaultLocale;
37
- return entry[resolvedLocale] ?? entry[defaultLocale] ?? key;
37
+ const requestedLocale = locale ?? defaultLocale;
38
+ // Plural category selection needs the locale the value actually came
39
+ // from, not the requested one — falling back to defaultLocale must
40
+ // apply defaultLocale's plural rules to that defaultLocale text.
41
+ const hitLocale = entry[requestedLocale] !== undefined ? requestedLocale : defaultLocale;
42
+ const value = entry[hitLocale];
43
+ if (value === undefined) return key;
44
+ return resolveTranslationValue(value, hitLocale, params);
38
45
  },
39
46
 
40
47
  getAllKeys(): string[] {
@@ -1,8 +1,10 @@
1
- const tables = new Map<string, Readonly<Record<string, string>>>();
1
+ import { resolveTranslationValue, type TranslationValue } from "../ui-types/plural";
2
+
3
+ const tables = new Map<string, Readonly<Record<string, TranslationValue>>>();
2
4
 
3
5
  export function registerMailTranslations(
4
6
  locale: string,
5
- bundle: Readonly<Record<string, string>>,
7
+ bundle: Readonly<Record<string, TranslationValue>>,
6
8
  ): void {
7
9
  const prev = tables.get(locale) ?? {};
8
10
  tables.set(locale, { ...prev, ...bundle });
@@ -16,13 +18,17 @@ export function hasMailTranslations(locale: string): boolean {
16
18
  export function mailT(
17
19
  locale: string,
18
20
  key: string,
19
- params?: Readonly<Record<string, string>>,
21
+ params?: Readonly<Record<string, string | number>>,
20
22
  ): string {
21
23
  const root = locale.split("-")[0] ?? locale;
22
- const raw =
23
- tables.get(locale)?.[key] ?? tables.get(root)?.[key] ?? tables.get("en")?.[key] ?? key;
24
- if (params === undefined) return raw;
25
- return raw.replace(/\{(\w+)\}/g, (_, name: string) => params[name] ?? `{${name}}`);
24
+ // Plural category selection needs the locale the value actually came
25
+ // from, not the requested one — a `pl` bundle falling back to `en`
26
+ // must apply English plural rules to that `en` text.
27
+ for (const localeToTry of [locale, root, "en"]) {
28
+ const value = tables.get(localeToTry)?.[key];
29
+ if (value !== undefined) return resolveTranslationValue(value, localeToTry, params);
30
+ }
31
+ return key;
26
32
  }
27
33
 
28
34
  /** Locale key mailT would actually use (exact → root → en). Use for appUrl
@@ -102,7 +102,9 @@ const archFeature = defineFeature("archtest", (r) => {
102
102
  "item:delete",
103
103
  z.object({ id: z.uuid() }),
104
104
  async (event, ctx) => executor.delete({ id: event.payload.id }, event.user, ctx.db),
105
- { access: { roles: ["Admin"] } },
105
+ // arch-item has no softDelete — a hard delete needs agent.risk: "high"
106
+ // to pass the irreversible-operation gate.
107
+ { access: { roles: ["Admin"] }, agent: { risk: "high" } },
106
108
  );
107
109
 
108
110
  r.queryHandler(
@@ -0,0 +1,249 @@
1
+ // Agent-risk floor for irreversible operations (delete on a non-softDelete
2
+ // entity, forget on any entity): the directly-dispatched entry handler must
3
+ // resolve agent.risk "high", or the executor gate refuses before any DB read.
4
+ // Real HTTP calls + setupTestStack — never createTestDispatcher.
5
+
6
+ import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test";
7
+ import * as z from "zod";
8
+ import { createEventStoreExecutor } from "../../db/event-store-executor";
9
+ import { buildEntityTable } from "../../db/table-builder";
10
+ import { createEntity, createTextField, defineFeature } from "../../engine";
11
+ import type { Registry } from "../../engine/types";
12
+ import { resolveAgentExposure } from "../../engine/types";
13
+ import { resetEventStore, setupTestStack, type TestStack, TestUsers } from "../../stack";
14
+ import { waitFor } from "../../testing";
15
+
16
+ const admin = TestUsers.admin;
17
+ const NONEXISTENT_ID = "00000000-0000-4000-8000-00000000dead";
18
+
19
+ const hardEntity = createEntity({
20
+ table: "gate_hard_items",
21
+ fields: { label: createTextField({ personal: false, reason: "test_fixture", required: true }) },
22
+ });
23
+ const hardTable = buildEntityTable("hard", hardEntity);
24
+ const hardExecutor = createEventStoreExecutor(hardTable, hardEntity, { entityName: "hard" });
25
+
26
+ const softEntity = createEntity({
27
+ table: "gate_soft_items",
28
+ fields: { label: createTextField({ personal: false, reason: "test_fixture", required: true }) },
29
+ softDelete: true,
30
+ });
31
+ const softTable = buildEntityTable("soft", softEntity);
32
+ const softExecutor = createEventStoreExecutor(softTable, softEntity, { entityName: "soft" });
33
+
34
+ const jobForgetRuns: Array<{ readonly id: string }> = [];
35
+
36
+ const gateFeature = defineFeature("gatetest", (r) => {
37
+ r.entity("hard", hardEntity);
38
+ r.entity("soft", softEntity);
39
+
40
+ r.crud("hard", hardEntity, {
41
+ write: { access: { roles: ["Admin"] } },
42
+ read: { access: { roles: ["Admin"] } },
43
+ });
44
+ r.crud("soft", softEntity, {
45
+ write: { access: { roles: ["Admin"] } },
46
+ read: { access: { roles: ["Admin"] } },
47
+ });
48
+
49
+ r.writeHandler(
50
+ "forget-hard-mid",
51
+ z.object({ id: z.uuid() }),
52
+ async (event, ctx) => hardExecutor.forget({ id: event.payload.id }, event.user, ctx.db),
53
+ { access: { roles: ["Admin"] } },
54
+ );
55
+
56
+ r.writeHandler(
57
+ "forget-hard-high",
58
+ z.object({ id: z.uuid() }),
59
+ async (event, ctx) => hardExecutor.forget({ id: event.payload.id }, event.user, ctx.db),
60
+ { access: { roles: ["Admin"] }, agent: { risk: "high" } },
61
+ );
62
+
63
+ r.writeHandler(
64
+ "delete-hard-mid",
65
+ z.object({ id: z.uuid() }),
66
+ async (event, ctx) => hardExecutor.delete({ id: event.payload.id }, event.user, ctx.db),
67
+ { access: { roles: ["Admin"] } },
68
+ );
69
+
70
+ r.writeHandler(
71
+ "delete-soft-mid",
72
+ z.object({ id: z.uuid() }),
73
+ async (event, ctx) => softExecutor.delete({ id: event.payload.id }, event.user, ctx.db),
74
+ { access: { roles: ["Admin"] } },
75
+ );
76
+
77
+ r.writeHandler(
78
+ "delegate-write-to-forget-high",
79
+ z.object({ id: z.uuid() }),
80
+ async (event, ctx) => ctx.write("gatetest:write:forget-hard-high", { id: event.payload.id }),
81
+ { access: { roles: ["Admin"] } },
82
+ );
83
+
84
+ r.writeHandler(
85
+ "delegate-write-as-to-forget-high",
86
+ z.object({ id: z.uuid() }),
87
+ async (event, ctx) =>
88
+ ctx.writeAs(event.user, "gatetest:write:forget-hard-high", { id: event.payload.id }),
89
+ { access: { roles: ["Admin"] } },
90
+ );
91
+
92
+ r.job("forgetHardJob", { trigger: { manual: true }, retries: 0 }, async (payload, ctx) => {
93
+ const id = payload["id"] as string; // @cast-boundary dynamic-key
94
+ await hardExecutor.forget({ id }, ctx.systemUser, ctx.db);
95
+ jobForgetRuns.push({ id });
96
+ });
97
+ });
98
+
99
+ function errorReason(details: unknown): string | undefined {
100
+ if (!details || typeof details !== "object" || !("reason" in details)) return undefined;
101
+ return typeof details.reason === "string" ? details.reason : undefined;
102
+ }
103
+
104
+ let stack: TestStack;
105
+
106
+ beforeAll(async () => {
107
+ stack = await setupTestStack({ features: [gateFeature], jobs: { consumerLane: "worker" } });
108
+ });
109
+
110
+ afterAll(async () => {
111
+ await stack.cleanup();
112
+ });
113
+
114
+ afterEach(async () => {
115
+ await resetEventStore(stack, ["gate_hard_items", "gate_soft_items"]);
116
+ });
117
+
118
+ async function createHard(label: string): Promise<string> {
119
+ const { id } = await stack.http.writeOk<{ id: string }>(
120
+ "gatetest:write:hard:create",
121
+ { label },
122
+ admin,
123
+ );
124
+ return id;
125
+ }
126
+
127
+ async function createSoft(label: string): Promise<string> {
128
+ const { id } = await stack.http.writeOk<{ id: string }>(
129
+ "gatetest:write:soft:create",
130
+ { label },
131
+ admin,
132
+ );
133
+ return id;
134
+ }
135
+
136
+ // The entity-convention "detail" query returns `data: null` for a missing
137
+ // row instead of an HTTP error — assert via queryOk + null, not queryErr.
138
+ async function hardRowExists(id: string): Promise<boolean> {
139
+ const row = await stack.http.queryOk("gatetest:query:hard:detail", { id }, admin);
140
+ return row !== null;
141
+ }
142
+
143
+ describe("executor.forget — always irreversible", () => {
144
+ test("mid-risk handler is denied, row survives", async () => {
145
+ const id = await createHard("survives-mid-forget");
146
+ const err = await stack.http.writeErr("gatetest:write:forget-hard-mid", { id }, admin);
147
+ expect(err.httpStatus).toBe(403);
148
+ expect(err.code).toBe("access_denied");
149
+ expect(errorReason(err.details)).toBe("irreversible_operation_requires_high_risk");
150
+ expect(err.message).toContain("gatetest:write:forget-hard-mid");
151
+ expect(await hardRowExists(id)).toBe(true);
152
+ });
153
+
154
+ test("high-risk handler succeeds, row is gone", async () => {
155
+ const id = await createHard("gone-after-high-forget");
156
+ await stack.http.writeOk("gatetest:write:forget-hard-high", { id }, admin);
157
+ expect(await hardRowExists(id)).toBe(false);
158
+ });
159
+
160
+ test("nonexistent id at mid-risk still answers 403, not 404", async () => {
161
+ const err = await stack.http.writeErr(
162
+ "gatetest:write:forget-hard-mid",
163
+ { id: NONEXISTENT_ID },
164
+ admin,
165
+ );
166
+ expect(err.httpStatus).toBe(403);
167
+ expect(err.code).toBe("access_denied");
168
+ });
169
+ });
170
+
171
+ describe("executor.delete — irreversible only without softDelete", () => {
172
+ test("mid-risk handler on a hard-delete entity is denied", async () => {
173
+ const id = await createHard("survives-mid-delete");
174
+ const err = await stack.http.writeErr("gatetest:write:delete-hard-mid", { id }, admin);
175
+ expect(err.httpStatus).toBe(403);
176
+ expect(errorReason(err.details)).toBe("irreversible_operation_requires_high_risk");
177
+ });
178
+
179
+ test("mid-risk handler on a softDelete entity succeeds", async () => {
180
+ const id = await createSoft("soft-delete-ok");
181
+ await stack.http.writeOk("gatetest:write:delete-soft-mid", { id }, admin);
182
+ const row = await stack.http.queryOk("gatetest:query:soft:detail", { id }, admin);
183
+ expect(row).toBeNull();
184
+ });
185
+ });
186
+
187
+ describe("delegation via ctx.write/writeAs does not inherit the inner handler's risk", () => {
188
+ test("mid handler delegating via ctx.write to a high forget handler is denied", async () => {
189
+ const id = await createHard("delegate-write-denied");
190
+ const err = await stack.http.writeErr(
191
+ "gatetest:write:delegate-write-to-forget-high",
192
+ { id },
193
+ admin,
194
+ );
195
+ expect(err.httpStatus).toBe(403);
196
+ expect(errorReason(err.details)).toBe("irreversible_operation_requires_high_risk");
197
+ expect(await hardRowExists(id)).toBe(true);
198
+ });
199
+
200
+ test("mid handler delegating via ctx.writeAs to a high forget handler is denied", async () => {
201
+ const id = await createHard("delegate-write-as-denied");
202
+ const err = await stack.http.writeErr(
203
+ "gatetest:write:delegate-write-as-to-forget-high",
204
+ { id },
205
+ admin,
206
+ );
207
+ expect(err.httpStatus).toBe(403);
208
+ expect(errorReason(err.details)).toBe("irreversible_operation_requires_high_risk");
209
+ expect(await hardRowExists(id)).toBe(true);
210
+ });
211
+
212
+ test("calling the high handler directly still succeeds", async () => {
213
+ const id = await createHard("direct-high-ok");
214
+ await stack.http.writeOk("gatetest:write:forget-hard-high", { id }, admin);
215
+ expect(await hardRowExists(id)).toBe(false);
216
+ });
217
+ });
218
+
219
+ describe("standard entity-convention delete handler", () => {
220
+ test("resolves to high risk on a non-softDelete entity, mid on a softDelete one", () => {
221
+ const registry = stack.registry as Registry;
222
+ const hardDelete = registry.getWriteHandler("gatetest:write:hard:delete");
223
+ const softDelete = registry.getWriteHandler("gatetest:write:soft:delete");
224
+ if (!hardDelete || !softDelete) throw new Error("delete handlers missing from registry");
225
+ expect(resolveAgentExposure(hardDelete, "write").risk).toBe("high");
226
+ expect(resolveAgentExposure(softDelete, "write").risk).toBe("mid");
227
+ });
228
+
229
+ test("succeeds over HTTP on the non-softDelete entity", async () => {
230
+ const id = await createHard("standard-delete-ok");
231
+ await stack.http.writeOk("gatetest:write:hard:delete", { id }, admin);
232
+ expect(await hardRowExists(id)).toBe(false);
233
+ });
234
+ });
235
+
236
+ describe("jobs have no entry handler — the gate stays out of their way", () => {
237
+ test("a job calling executor.forget without a write-handler entry runs through", async () => {
238
+ jobForgetRuns.length = 0;
239
+ const id = await createHard("job-forget-ok");
240
+ await stack.jobRunner?.dispatch("gatetest:job:forget-hard-job", {
241
+ id,
242
+ tenantId: admin.tenantId,
243
+ });
244
+ await waitFor(() => {
245
+ expect(jobForgetRuns.some((run) => run.id === id)).toBe(true);
246
+ });
247
+ expect(await hardRowExists(id)).toBe(false);
248
+ });
249
+ });
@@ -13,6 +13,7 @@ import { createDerivativesContext } from "../derivatives/derivatives-context";
13
13
  import type { defineTransitions } from "../engine/state-machine";
14
14
  import type { EffectiveFeaturesResolver } from "../engine/tier-resolver-extension";
15
15
  import type {
16
+ AgentRisk,
16
17
  AggregateStreamHandle,
17
18
  AppContext,
18
19
  AppendEventArgs,
@@ -28,7 +29,7 @@ import type {
28
29
  SessionUser,
29
30
  WriteResult,
30
31
  } from "../engine/types";
31
- import { isRateLimitDisabled } from "../engine/types";
32
+ import { isRateLimitDisabled, resolveAgentExposure } from "../engine/types";
32
33
  import type { TenantId } from "../engine/types/identifiers";
33
34
  import {
34
35
  FeatureDisabledError,
@@ -877,6 +878,25 @@ export async function buildHandlerContext(
877
878
  : handlerContext;
878
879
  }
879
880
 
881
+ // Resolves the agent risk the irreversible-operation gate should attribute to
882
+ // `type` when it becomes the request's entry handler — same defaulting rule
883
+ // (resolveAgentExposure) the agent manifest itself uses, so the two never diverge.
884
+ function resolveEntryHandlerRisk(
885
+ registry: Registry,
886
+ type: string,
887
+ operation: "query" | "write" | "stream",
888
+ ): AgentRisk {
889
+ if (operation === "write") {
890
+ return resolveAgentExposure(registry.getWriteHandler(type) ?? {}, "write").risk;
891
+ }
892
+ if (operation === "stream") {
893
+ // StreamHandlerDef carries no `agent` hint — streams can never declare
894
+ // "high", so nested irreversible writes stay conservatively denied.
895
+ return "mid";
896
+ }
897
+ return resolveAgentExposure(registry.getQueryHandler(type) ?? {}, "query").risk;
898
+ }
899
+
880
900
  // Wrap handler execution in a dispatcher.handler span AND emit the standard
881
901
  // dispatcher metrics (duration + error counter). Errors are re-thrown so
882
902
  // control flow stays identical to the uninstrumented path.
@@ -916,7 +936,11 @@ export async function runHandlerInstrumented<T>(
916
936
  // #3043 — everything the handler writes, including entity-executor
917
937
  // writes below it, is attributed to this handler.
918
938
  const result = await runWithOrigin(
919
- { handler: type, feature: registry.getHandlerFeature(type) },
939
+ {
940
+ handler: type,
941
+ feature: registry.getHandlerFeature(type),
942
+ entryHandler: { qn: type, risk: resolveEntryHandlerRisk(registry, type, operation) },
943
+ },
920
944
  inner,
921
945
  );
922
946
  if (operation === "write" && isFailedWriteResult(result)) {
@@ -978,8 +1002,13 @@ export async function* runStreamInstrumented<T>(
978
1002
  // generator suspension, so wrapping inner() once would leave every event a
979
1003
  // stream writes unattributed.
980
1004
  const inScope = <R>(fn: () => R): R =>
981
- runWithOrigin({ handler: type, feature: registry.getHandlerFeature(type) }, () =>
982
- observabilityContext.run({ activeSpan: span }, fn),
1005
+ runWithOrigin(
1006
+ {
1007
+ handler: type,
1008
+ feature: registry.getHandlerFeature(type),
1009
+ entryHandler: { qn: type, risk: resolveEntryHandlerRisk(registry, type, "stream") },
1010
+ },
1011
+ () => observabilityContext.run({ activeSpan: span }, fn),
983
1012
  );
984
1013
  try {
985
1014
  let next = await inScope(() => it.next());
@@ -50,6 +50,10 @@ export type { PruneEventsOptions, PruneEventsResult } from "./event-retention";
50
50
  export { ConsumerLagError, pruneEvents } from "./event-retention";
51
51
  export type { IdempotencyGuard } from "./idempotency";
52
52
  export { createIdempotencyGuard } from "./idempotency";
53
+ export {
54
+ assertIrreversibleOperationAllowed,
55
+ isIrreversibleEntityVerb,
56
+ } from "./irreversible-operation-gate";
53
57
  export type { LifecycleHooks, SystemHookDef, SystemHooks } from "./lifecycle-pipeline";
54
58
  export { createLifecycleHooks } from "./lifecycle-pipeline";
55
59
  export type { MspRebuildDeps } from "./msp-rebuild";
@@ -0,0 +1,33 @@
1
+ import { requestContext } from "../api/request-context";
2
+ import type { EntityDefinition } from "../engine/types";
3
+ import { AccessDeniedError, FrameworkReasons } from "../errors";
4
+
5
+ // forget hard-purges regardless of softDelete; delete is only irreversible
6
+ // when the entity has no soft-delete recovery path.
7
+ export function isIrreversibleEntityVerb(
8
+ verb: "delete" | "forget",
9
+ entity: EntityDefinition,
10
+ ): boolean {
11
+ return verb === "forget" || !entity.softDelete;
12
+ }
13
+
14
+ // No entryHandler means the call didn't originate from a directly-dispatched
15
+ // handler (job, MSP/consumer apply, seed, afterCommit hook, HTTP route
16
+ // without a handler) — those are outside agent reach and stay ungated.
17
+ export function assertIrreversibleOperationAllowed(operation: string): void {
18
+ const entryHandler = requestContext.get()?.entryHandler;
19
+ // skip: no entry handler (see above) or the entry handler already resolves "high".
20
+ if (!entryHandler || entryHandler.risk === "high") return;
21
+ throw new AccessDeniedError({
22
+ message:
23
+ `Handler "${entryHandler.qn}" performs an irreversible ${operation} but resolves agent risk ` +
24
+ `"${entryHandler.risk}" — declare agent: { risk: "high" } on "${entryHandler.qn}". The ` +
25
+ "directly called handler must carry it; delegating via ctx.write/writeAs does not.",
26
+ details: {
27
+ reason: FrameworkReasons.irreversibleOperationRequiresHighRisk,
28
+ handler: entryHandler.qn,
29
+ operation,
30
+ resolvedRisk: entryHandler.risk,
31
+ },
32
+ });
33
+ }
@@ -0,0 +1,108 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { resolveTranslationValue, translationValueOtherText } from "../plural";
3
+
4
+ const messageCount = {
5
+ de: { one: "{count} ungelesene Nachricht", other: "{count} ungelesene Nachrichten" },
6
+ en: { one: "{count} unread message", other: "{count} unread messages" },
7
+ pl: {
8
+ one: "{count} nieprzeczytana wiadomość",
9
+ few: "{count} nieprzeczytane wiadomości",
10
+ many: "{count} nieprzeczytanych wiadomości",
11
+ other: "{count} nieprzeczytanej wiadomości",
12
+ },
13
+ } as const;
14
+
15
+ describe("resolveTranslationValue — plain strings", () => {
16
+ test("interpolates {name} placeholders", () => {
17
+ expect(resolveTranslationValue("Hello, {name}!", "en", { name: "Ada" })).toBe("Hello, Ada!");
18
+ });
19
+
20
+ test("leaves an unknown placeholder untouched", () => {
21
+ expect(resolveTranslationValue("Hello, {name}!", "en")).toBe("Hello, {name}!");
22
+ });
23
+ });
24
+
25
+ describe("resolveTranslationValue — plural forms", () => {
26
+ test("en: singular vs plural", () => {
27
+ expect(resolveTranslationValue(messageCount.en, "en", { count: 1 })).toBe("1 unread message");
28
+ expect(resolveTranslationValue(messageCount.en, "en", { count: 0 })).toBe("0 unread messages");
29
+ expect(resolveTranslationValue(messageCount.en, "en", { count: 2 })).toBe("2 unread messages");
30
+ expect(resolveTranslationValue(messageCount.en, "en", { count: 5 })).toBe("5 unread messages");
31
+ expect(resolveTranslationValue(messageCount.en, "en", { count: 21 })).toBe(
32
+ "21 unread messages",
33
+ );
34
+ });
35
+
36
+ test("de: singular vs plural", () => {
37
+ expect(resolveTranslationValue(messageCount.de, "de", { count: 1 })).toBe(
38
+ "1 ungelesene Nachricht",
39
+ );
40
+ expect(resolveTranslationValue(messageCount.de, "de", { count: 5 })).toBe(
41
+ "5 ungelesene Nachrichten",
42
+ );
43
+ });
44
+
45
+ test("pl: one/few/many CLDR categories", () => {
46
+ expect(resolveTranslationValue(messageCount.pl, "pl", { count: 1 })).toBe(
47
+ "1 nieprzeczytana wiadomość",
48
+ );
49
+ expect(resolveTranslationValue(messageCount.pl, "pl", { count: 2 })).toBe(
50
+ "2 nieprzeczytane wiadomości",
51
+ );
52
+ expect(resolveTranslationValue(messageCount.pl, "pl", { count: 5 })).toBe(
53
+ "5 nieprzeczytanych wiadomości",
54
+ );
55
+ expect(resolveTranslationValue(messageCount.pl, "pl", { count: 21 })).toBe(
56
+ "21 nieprzeczytanych wiadomości",
57
+ );
58
+ expect(resolveTranslationValue(messageCount.pl, "pl", { count: 22 })).toBe(
59
+ "22 nieprzeczytane wiadomości",
60
+ );
61
+ });
62
+
63
+ test("a formal BCP-47 subtag still resolves plural categories (renderer's <locale>-x-formal tier)", () => {
64
+ expect(resolveTranslationValue(messageCount.pl, "pl-x-formal", { count: 2 })).toBe(
65
+ "2 nieprzeczytane wiadomości",
66
+ );
67
+ });
68
+
69
+ test("falls back to `other` when the selected category has no form", () => {
70
+ const partial = { one: "one item", other: "{count} items" };
71
+ // pl "5" selects "many", which `partial` doesn't define.
72
+ expect(resolveTranslationValue(partial, "pl", { count: 5 })).toBe("5 items");
73
+ });
74
+
75
+ test("falls back to `other` when count is missing", () => {
76
+ expect(resolveTranslationValue(messageCount.en, "en")).toBe("{count} unread messages");
77
+ });
78
+
79
+ test("falls back to `other` when count is not a finite number", () => {
80
+ expect(resolveTranslationValue(messageCount.en, "en", { count: "many" })).toBe(
81
+ "many unread messages",
82
+ );
83
+ // count is present (interpolated verbatim) but non-finite, so the plural
84
+ // category still falls back to `other`.
85
+ expect(resolveTranslationValue(messageCount.en, "en", { count: Number.NaN })).toBe(
86
+ "NaN unread messages",
87
+ );
88
+ });
89
+
90
+ test("an invalid locale tag falls back to `other` without throwing", () => {
91
+ expect(() =>
92
+ resolveTranslationValue(messageCount.en, "not-a-locale-!!", { count: 1 }),
93
+ ).not.toThrow();
94
+ expect(resolveTranslationValue(messageCount.en, "not-a-locale-!!", { count: 1 })).toBe(
95
+ "1 unread messages",
96
+ );
97
+ });
98
+ });
99
+
100
+ describe("translationValueOtherText", () => {
101
+ test("returns a plain string as-is", () => {
102
+ expect(translationValueOtherText("Welcome")).toBe("Welcome");
103
+ });
104
+
105
+ test("returns the `other` form for a plural entry", () => {
106
+ expect(translationValueOtherText(messageCount.en)).toBe("{count} unread messages");
107
+ });
108
+ });
@@ -150,3 +150,9 @@ export {
150
150
  REFERENCE_LOOKUP_SOURCES,
151
151
  SYSTEM_REFERENCE_LABELS,
152
152
  } from "./list-row-meta";
153
+ export {
154
+ type PluralForms,
155
+ resolveTranslationValue,
156
+ type TranslationValue,
157
+ translationValueOtherText,
158
+ } from "./plural";
@@ -0,0 +1,69 @@
1
+ import type { PluralForms, TranslationValue } from "@cosmicdrift/kumiko-types/config";
2
+
3
+ export type { PluralForms, TranslationValue } from "@cosmicdrift/kumiko-types/config";
4
+
5
+ // Hermes on older Expo/React Native builds doesn't ship Intl.PluralRules —
6
+ // fall back to `other` there instead of hand-rolling CLDR plural rules.
7
+ const hasPluralRulesSupport = typeof Intl !== "undefined" && typeof Intl.PluralRules === "function";
8
+
9
+ // `null` cache entries mark a locale tag Intl.PluralRules rejected, so a bad
10
+ // tag doesn't retry the constructor (and its exception) on every call.
11
+ const pluralRulesCache = new Map<string, Intl.PluralRules | null>();
12
+
13
+ function pluralRulesFor(locale: string): Intl.PluralRules | null {
14
+ if (!hasPluralRulesSupport) return null;
15
+ const cached = pluralRulesCache.get(locale);
16
+ if (cached !== undefined) return cached;
17
+ let rules: Intl.PluralRules | null;
18
+ try {
19
+ rules = new Intl.PluralRules(locale);
20
+ } catch {
21
+ rules = null;
22
+ }
23
+ pluralRulesCache.set(locale, rules);
24
+ return rules;
25
+ }
26
+
27
+ function isFiniteCount(value: unknown): value is number {
28
+ return typeof value === "number" && Number.isFinite(value);
29
+ }
30
+
31
+ function pluralFormText(forms: PluralForms, locale: string, count: unknown): string {
32
+ if (!isFiniteCount(count)) return forms.other;
33
+ const rules = pluralRulesFor(locale);
34
+ if (rules === null) return forms.other;
35
+ const category = rules.select(count);
36
+ return forms[category] ?? forms.other;
37
+ }
38
+
39
+ function interpolate(
40
+ template: string,
41
+ params: Readonly<Record<string, unknown>> | undefined,
42
+ ): string {
43
+ if (params === undefined) return template;
44
+ return template.replace(/\{(\w+)\}/g, (_, name: string) => {
45
+ const value = params[name];
46
+ return value !== undefined ? String(value) : `{${name}}`;
47
+ });
48
+ }
49
+
50
+ /** Text-only reading of a TranslationValue — the CLDR `other` form for a
51
+ * plural entry, or the string as-is. For call sites that render a single
52
+ * string (agent manifests, labels) without a locale/count context. */
53
+ export function translationValueOtherText(value: TranslationValue): string {
54
+ return typeof value === "string" ? value : value.other;
55
+ }
56
+
57
+ /** Resolve a TranslationValue to display text: a plural entry picks its CLDR
58
+ * category via `Intl.PluralRules` from `params.count`, falling back to
59
+ * `other` when `count` is missing, non-finite, or `locale` is invalid.
60
+ * `{name}` interpolation then applies to the resolved string — an unknown
61
+ * placeholder is left as-is. */
62
+ export function resolveTranslationValue(
63
+ value: TranslationValue,
64
+ locale: string,
65
+ params?: Readonly<Record<string, unknown>>,
66
+ ): string {
67
+ const text = typeof value === "string" ? value : pluralFormText(value, locale, params?.["count"]);
68
+ return interpolate(text, params);
69
+ }