@ultimat3/core 23.0.0 → 25.0.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.
Files changed (98) hide show
  1. package/CLAUDE.md +29 -26
  2. package/README.md +98 -30
  3. package/package.json +4 -7
  4. package/src/actor.ts +9 -0
  5. package/src/address-class.ts +40 -4
  6. package/src/assert.ts +9 -5
  7. package/src/audit.ts +144 -0
  8. package/src/aws-sigv4.ts +275 -0
  9. package/src/backoff.ts +16 -0
  10. package/src/bunfs.ts +17 -0
  11. package/src/client-dispatch.ts +24 -3
  12. package/src/client-flight.ts +68 -13
  13. package/src/client-problem.ts +62 -6
  14. package/src/client-retry-after.ts +47 -0
  15. package/src/client-transport.ts +3 -1
  16. package/src/client-wire.ts +27 -3
  17. package/src/config-ai.ts +32 -0
  18. package/src/config-defaults.ts +53 -0
  19. package/src/config-fixes.ts +0 -10
  20. package/src/config-health.ts +9 -2
  21. package/src/config-jobs.ts +51 -0
  22. package/src/config-keys.ts +170 -0
  23. package/src/config-mail.ts +73 -0
  24. package/src/config-merge.ts +9 -1
  25. package/src/config-navigation.ts +1 -25
  26. package/src/config-pwa.ts +42 -5
  27. package/src/config-removed.ts +131 -0
  28. package/src/config-shape.ts +79 -0
  29. package/src/config-site.ts +14 -3
  30. package/src/config.ts +141 -173
  31. package/src/context.ts +29 -13
  32. package/src/cookie.ts +299 -0
  33. package/src/core-error-codes.ts +2 -0
  34. package/src/cursor-page.ts +41 -0
  35. package/src/cursor.ts +26 -5
  36. package/src/decimal-order.ts +5 -4
  37. package/src/deprecation.ts +77 -0
  38. package/src/dev-secrets.ts +18 -7
  39. package/src/drain-deadline.ts +43 -0
  40. package/src/env-example.ts +9 -29
  41. package/src/error-reporter-sentry.ts +7 -3
  42. package/src/errors.ts +18 -9
  43. package/src/exports/error-contract.ts +0 -1
  44. package/src/exports/observability.ts +1 -1
  45. package/src/exports/secrets.ts +3 -0
  46. package/src/finite-option.ts +1 -1
  47. package/src/flight-gate.ts +43 -16
  48. package/src/fnv1a.ts +19 -0
  49. package/src/generation-fence.ts +1 -1
  50. package/src/health-disclosure.ts +43 -0
  51. package/src/host-rules.ts +28 -1
  52. package/src/html-escape.ts +24 -0
  53. package/src/ids.ts +7 -7
  54. package/src/image/canvas.ts +76 -5
  55. package/src/image/errors.ts +3 -1
  56. package/src/image/pipeline.ts +17 -5
  57. package/src/image/png-pixels.ts +29 -6
  58. package/src/image/probe.ts +7 -2
  59. package/src/image/raster.ts +27 -2
  60. package/src/index.ts +99 -33
  61. package/src/iso-date.ts +1 -1
  62. package/src/lifecycle-errors.ts +1 -1
  63. package/src/lifecycle-readiness.ts +60 -2
  64. package/src/lifecycle-signals.ts +27 -2
  65. package/src/lifecycle-types.ts +96 -0
  66. package/src/lifecycle.ts +44 -133
  67. package/src/locale-direction.ts +1 -1
  68. package/src/logger.ts +92 -16
  69. package/src/mcp-exposure.ts +70 -8
  70. package/src/measurement-actor.ts +16 -1
  71. package/src/metric-errors.ts +32 -0
  72. package/src/metric-registry.ts +151 -0
  73. package/src/metric-series.ts +94 -0
  74. package/src/metrics.ts +9 -255
  75. package/src/nearest-name.ts +11 -2
  76. package/src/otlp-metric-exporter.ts +1 -1
  77. package/src/otlp-span-exporter.ts +1 -1
  78. package/src/otlp.ts +44 -13
  79. package/src/page.ts +4 -2
  80. package/src/pg-executor.ts +15 -0
  81. package/src/public-cause.ts +37 -0
  82. package/src/registrar.ts +22 -4
  83. package/src/retry.ts +40 -7
  84. package/src/route-rank.ts +36 -0
  85. package/src/same-origin.ts +1 -1
  86. package/src/sampler.ts +6 -2
  87. package/src/secrets-errors.ts +14 -3
  88. package/src/secrets-key-file.ts +139 -0
  89. package/src/secrets-store.ts +32 -16
  90. package/src/service.ts +5 -5
  91. package/src/single-flight.ts +1 -1
  92. package/src/source-mask.ts +14 -8
  93. package/src/store-mode.ts +23 -0
  94. package/src/telemetry.ts +1 -1
  95. package/src/theme-storage.ts +12 -0
  96. package/src/type-pins.ts +51 -1
  97. package/src/image/fixtures.ts +0 -263
  98. package/src/time-zone-name.ts +0 -14
package/src/config.ts CHANGED
@@ -2,31 +2,42 @@
2
2
  // defaults, validated eagerly, and composable so a big app can split it across `config/*.ts`
3
3
  // without inventing a second config mechanism.
4
4
 
5
- import { CURRENCY_CODE_PATTERN } from '@ultimat3/schema';
6
5
  // Same rule for the same reason: `app.config.ts` CONSUMES the cache tier names, it does not own
7
6
  // them. Declaring them here is what let `cache.tiers` and the ladder `@ultimat3/cache` orders by
8
7
  // drift into two vocabularies with no map between them (issue #293).
9
8
  import { CACHE_TIERS, type CacheTierName } from './cache-vocabulary';
9
+ import type { AiConfig, AiConfigInput } from './config-ai';
10
10
  import { countIssue } from './config-count';
11
- import { BASE_FIX, CACHE_TIER_FIX, TIMEZONE_FIX } from './config-fixes';
12
- import type { DrainConfig, HealthConfig } from './config-health';
13
- import { readinessModeIssue } from './config-health';
11
+ import { configDefaults } from './config-defaults';
12
+ import { BASE_FIX, CACHE_TIER_FIX } from './config-fixes';
13
+ import { type DrainConfig, type HealthConfig, readinessModeIssue } from './config-health';
14
14
  import type { IslandsConfig, IslandsSectionInput } from './config-islands';
15
15
  import { islandsIssues, mergeIslands } from './config-islands';
16
+ import { type JobsConcurrency, jobsConcurrencyIssues } from './config-jobs';
17
+ import { refuseUnknownKeys } from './config-keys';
18
+ import { type MailConfig, type MailSectionInput, mailIssues, mergeMail } from './config-mail';
16
19
  import { type Input, lastSaid, layered } from './config-merge';
17
20
  import type { NavigationConfig, NavigationSectionInput } from './config-navigation';
18
21
  import { mergeNavigation, navigationIssues } from './config-navigation';
19
22
  import type { PwaConfig, PwaOfflineConfig } from './config-pwa';
20
23
  import { PWA_FIX, pwaIssues } from './config-pwa';
24
+ import { removedKeyFix, removedKeyIssue, removedKeysIn } from './config-removed';
25
+ import {
26
+ booleanIssue,
27
+ nameListIssues,
28
+ oneOfIssue,
29
+ routePathIssue,
30
+ shapeIssues,
31
+ } from './config-shape';
21
32
  import type { SeoConfig, SiteConfig, SiteSectionsInput } from './config-site';
22
33
  import { mergeSite, siteIssues } from './config-site';
34
+ import { drainIssues } from './drain-deadline';
23
35
  import { describeValue } from './error-render';
24
36
  import { ConfigInvalidError } from './errors';
25
- import { defaultReadinessGraceMs, readinessGraceIssue } from './lifecycle-grace';
26
37
  import { ROLES, type Role } from './roles';
27
- import { isIanaZoneName } from './time-zone-name';
28
38
 
29
- export type ThemeMode = 'light' | 'dark' | 'system';
39
+ export const THEME_MODES = ['light', 'dark', 'system'] as const;
40
+ export type ThemeMode = (typeof THEME_MODES)[number];
30
41
  /**
31
42
  * The buses `@ultimat3/realtime`'s `selectTransport` builds, and nothing else. `'redis'` was in
32
43
  * this union until 22.0.0 with no Redis transport anywhere: it booted whatever `NATS_URL` chose.
@@ -34,10 +45,12 @@ export type ThemeMode = 'light' | 'dark' | 'system';
34
45
  export const REALTIME_TRANSPORTS = ['memory', 'nats'] as const;
35
46
  export type RealtimeTransport = (typeof REALTIME_TRANSPORTS)[number];
36
47
 
48
+ /**
49
+ * No `tokens` (deleted in 25.0.0): read by nothing once the theme seam landed — the app's theme is
50
+ * `export const brand = defineTheme(…)` from `@ultimat3/ui`. A second theming path nothing read.
51
+ */
37
52
  export interface ThemeConfig {
38
53
  readonly defaultMode: ThemeMode;
39
- /** Semantic design tokens. Raw hex is a lint error in components, never here. */
40
- readonly tokens: Readonly<Record<string, string>>;
41
54
  }
42
55
 
43
56
  /**
@@ -99,8 +112,10 @@ export const INBOX_RETENTION_KEYS = ['inboxReadRetentionMs', 'inboxUnreadRetenti
99
112
  * 3 applied to configuration: a value that produces neither a build error nor a runtime effect is
100
113
  * worse than no field, because an SRE sets `poolSize: 3`, redeploys, and nothing changes.
101
114
  */
115
+ const DATABASE_DRIVERS = ['postgres'] as const;
116
+
102
117
  export interface DatabaseConfig {
103
- readonly driver: 'postgres';
118
+ readonly driver: (typeof DATABASE_DRIVERS)[number];
104
119
  readonly ssl: boolean;
105
120
  }
106
121
 
@@ -121,26 +136,32 @@ export interface DatabaseConfig {
121
136
  */
122
137
  export interface CacheConfig {
123
138
  readonly defaultTtlMs: number;
124
- /** Order is fixed by `TIER_ORDER`; listing order here selects rungs, it does not rank them. */
139
+ /** Order is fixed by `CACHE_TIERS`; listing order here selects rungs, it does not rank them. */
125
140
  readonly tiers: readonly CacheTierName[];
126
141
  }
127
142
 
143
+ const JOB_BACKOFFS = ['exponential', 'fixed'] as const;
144
+
128
145
  export interface JobsConfig {
129
146
  /**
130
147
  * No `driver`. It accepted `'postgres' | 'redis' | 'nats'`, was read by NOTHING, and boot always
131
- * built `createPgDriver` — so `jobs: { driver: 'redis' }` did not throw, did not warn, and
132
- * silently gave you Postgres. Deleted 2026-08-20, and it is the worse shape of the same defect
133
- * `realtime.heartbeatMs` was: a knob that fails SILENTLY in the dangerous direction.
148
+ * built the Postgres driver — so `jobs: { driver: 'redis' }` did not throw, did not warn, and
149
+ * silently gave you Postgres. Deleted 2026-08-20 (5.0.0); since 25.0.0 a config still writing it
150
+ * is REFUSED by name (`config-removed.ts`) rather than carried through the spread.
134
151
  *
135
- * The seam that works is `setJobDriver(driver)` — `setJobDriver(createPgDriver({ executor }))`,
136
- * or `setJobDriver(createMemoryDriver())` in a test. Swap the driver, zero job-code change, which
137
- * is the whole of what the `JobDriver` interface buys. There is no config line, and one that
138
- * cannot be honoured is worse than none.
152
+ * The seam that works is `setJobDriver(driver)` —
153
+ * `setJobDriver(postgresJobDriver({ executor }))`, or `setJobDriver(memoryJobDriver())` in a
154
+ * test. Swap the driver, zero job-code change, which is the whole of what the `JobDriver`
155
+ * interface buys. There is no config line, and one that cannot be honoured is worse than none.
139
156
  */
140
157
  readonly queues: readonly string[];
141
- readonly concurrency: number;
158
+ /**
159
+ * Slots per worker process: one number for every queue it serves, or a table per queue
160
+ * (`{ banks: 4, 'banks-long': 2 }`, a queue it does not name at `JOBS_CONCURRENCY_DEFAULT`).
161
+ */
162
+ readonly concurrency: JobsConcurrency;
142
163
  readonly maxAttempts: number;
143
- readonly backoff: 'exponential' | 'fixed';
164
+ readonly backoff: (typeof JOB_BACKOFFS)[number];
144
165
  readonly visibilityTimeoutMs: number;
145
166
  }
146
167
 
@@ -153,37 +174,28 @@ export interface JobsConfig {
153
174
  *
154
175
  * No `tier` either (deleted 2026-08-23): no file read it, so `tier: 'local-first'` bought nothing.
155
176
  * An app's realtime tier is what it DECLARES — a `channel()` topic, a `live: true` query, a local
156
- * store — never a config key; `transport` and `urlEnv` are the only fields any code reads.
177
+ * store — never a config key. `transport`, `urlEnv` and the two per-actor caps (read by the `sync`
178
+ * role into its registry and node) are the fields code reads.
157
179
  */
158
180
  export interface RealtimeConfig {
159
181
  readonly enabled: boolean;
160
182
  readonly transport: RealtimeTransport;
161
183
  readonly urlEnv: string | undefined;
162
- }
163
-
164
- export interface McpConfig {
165
- readonly expose: boolean;
166
- readonly path: string;
184
+ /** Live subscriptions one actor (or anonymous network) may hold per sync node. Unset: 1,000. */
185
+ readonly maxSubscriptionsPerActor?: number | undefined;
186
+ /** Sockets one actor (or anonymous network) may hold per sync node. Unset: 16. */
187
+ readonly maxSocketsPerActor?: number | undefined;
167
188
  }
168
189
 
169
190
  /**
170
- * No `modelEnv`. It named the env KEY holding the model id, "so no model string is baked into the
171
- * image" — and its only reader was this file's own merge, copying input to output. Nothing
172
- * consumed the merged value, so `modelEnv: 'ANTHROPIC_MODEL'` selected no model: `@ultimat3/ai`
173
- * reads env for API KEYS only, and the model is `request.model ?? DEFAULT_MODEL`, a compile-time
174
- * constant in `models.ts`. The exact thing the key existed to prevent is what it delivered.
175
- * Deleted 2026-08 — pass `model` on the request, or read your own env key and pass it.
191
+ * No `locales` / `defaultLocale`, `defaultTimeZone` or `defaultCurrency` (deleted in 25.0.0, and
192
+ * REFUSED by name when written — `config-removed.ts`). The locales were a second declaration of
193
+ * `defineCatalogs({ default })`; the zone and the currency were read by nothing, and an ambient
194
+ * default for either is the defect the framework forbids (every format call takes its zone, every
195
+ * `Money` its currency).
176
196
  */
177
- export interface AiConfig {
178
- readonly mcp: McpConfig;
179
- }
180
-
181
197
  export interface AppConfig {
182
198
  readonly name: string;
183
- readonly locales: readonly string[];
184
- readonly defaultLocale: string;
185
- readonly defaultTimeZone: string;
186
- readonly defaultCurrency: string;
187
199
  readonly theme: ThemeConfig;
188
200
  readonly auth: AuthConfig;
189
201
  readonly pwa: PwaConfig;
@@ -200,11 +212,7 @@ export interface AppConfig {
200
212
  readonly seo: SeoConfig;
201
213
  readonly navigation: NavigationConfig;
202
214
  readonly islands: IslandsConfig;
203
- }
204
-
205
- /** `mcp` is the only member, and it is NESTED — `Input<AiConfig>` would make it all-or-nothing. */
206
- export interface AiConfigInput {
207
- readonly mcp?: Input<McpConfig> | undefined;
215
+ readonly mail: MailConfig;
208
216
  }
209
217
 
210
218
  /**
@@ -221,12 +229,9 @@ export interface PwaConfigInput extends Omit<Input<PwaConfig>, 'offline'> {
221
229
  export interface AppConfigInput
222
230
  extends SiteSectionsInput,
223
231
  NavigationSectionInput,
224
- IslandsSectionInput {
232
+ IslandsSectionInput,
233
+ MailSectionInput {
225
234
  readonly name: string;
226
- readonly locales?: readonly string[] | undefined;
227
- readonly defaultLocale?: string | undefined;
228
- readonly defaultTimeZone?: string | undefined;
229
- readonly defaultCurrency?: string | undefined;
230
235
  readonly theme?: Input<ThemeConfig> | undefined;
231
236
  readonly auth?: Input<AuthConfig> | undefined;
232
237
  readonly pwa?: PwaConfigInput | undefined;
@@ -246,113 +251,52 @@ export type AppConfigOverlay = Omit<AppConfigInput, 'name'> & { readonly name?:
246
251
 
247
252
  const NAME_RE = /^[a-z][a-z0-9-]{1,63}$/;
248
253
 
249
- /**
250
- * Built from `@ultimat3/schema`'s `CURRENCY_CODE_PATTERN`, the framework's ONE declaration of what
251
- * an ISO 4217 code looks like — the same source `isCurrencyCode`, the published OpenAPI `pattern`
252
- * and `@ultimat3/entity`'s Postgres CHECK all derive from. It was a character-for-character copy
253
- * here until the `core -> schema` edge was declared (`scripts/lib/tiers.ts`), held equal only by a
254
- * pin test in `@ultimat3/cli`.
255
- */
256
- const CURRENCY_RE = new RegExp(CURRENCY_CODE_PATTERN);
257
-
258
- function isLocale(value: string): boolean {
259
- try {
260
- return Intl.getCanonicalLocales(value).length === 1;
261
- } catch {
262
- return false;
263
- }
264
- }
265
-
266
- type Sectioned = 'name' | 'site' | 'seo' | 'navigation' | 'islands';
267
- function defaults(name: string): Omit<AppConfig, Sectioned> {
268
- return {
269
- locales: ['en'],
270
- defaultLocale: 'en',
271
- defaultTimeZone: 'UTC',
272
- defaultCurrency: 'USD',
273
- theme: { defaultMode: 'system', tokens: {} },
274
- auth: { signInPath: null },
275
- pwa: {
276
- enabled: false,
277
- offline: { fallback: null, image: null, font: null, neverCache: [], personalPages: 'never' },
278
- backgroundSync: false,
279
- push: false,
280
- name: '',
281
- colors: undefined,
282
- },
283
- roles: [...ROLES],
284
- database: { driver: 'postgres', ssl: false },
285
- cache: { defaultTtlMs: 60_000, tiers: ['request-memo', 'lru'] },
286
- jobs: {
287
- queues: [`${name}-default`],
288
- concurrency: 8,
289
- maxAttempts: 5,
290
- backoff: 'exponential',
291
- visibilityTimeoutMs: 30_000,
292
- },
293
- // ON by default since 22.0.0, when the boot began obeying the key: an app with no section
294
- // keeps the `sync` node it always got, and `enabled: false` is the explicit opt-out.
295
- realtime: { enabled: true, transport: 'memory', urlEnv: undefined },
296
- notify: { inboxReadRetentionMs: undefined, inboxUnreadRetentionMs: undefined },
297
- ai: { mcp: { expose: true, path: '/mcp' } },
298
- // Read from the process env when the config is DEFINED — the same env the drain will run in.
299
- drain: { readinessGraceMs: defaultReadinessGraceMs() },
300
- health: { readiness: 'dependencies' },
301
- };
302
- }
303
-
304
254
  function validate(config: AppConfig): void {
305
255
  const issues: string[] = [];
306
- // Zero or one entry: the zone's own remedy, carried only when the zone is what failed.
307
- const zoneFix: string[] = [];
308
- // Same shape, and it exists for the upgrade: an 8.0.0 app carrying `['memo', 'shared']` in an
309
- // untyped config file reaches here rather than the compiler, and needs the new spelling.
256
+ // Zero or one entry, and it exists for the upgrade: an 8.0.0 app carrying `['memo', 'shared']`
257
+ // in an untyped config file reaches here rather than the compiler, and needs the new spelling.
310
258
  const tierFix: string[] = [];
311
259
  // Same shape again: carried only when an installable app is missing what an install needs.
312
260
  const pwaFix: string[] = [];
313
261
 
314
- if (!NAME_RE.test(config.name)) {
262
+ // `typeof` first: `NAME_RE.test(undefined)` tests the string "undefined", which matches.
263
+ if (typeof config.name !== 'string') {
264
+ issues.push(`name must be a string like "my-app", not ${describeValue(config.name)}`);
265
+ } else if (!NAME_RE.test(config.name)) {
315
266
  issues.push(`name "${config.name}" must match ${String(NAME_RE)}`);
316
267
  }
317
- if (config.locales.length === 0) issues.push('locales must list at least one locale');
318
- for (const locale of config.locales) {
319
- if (!isLocale(locale)) issues.push(`locales contains "${locale}", not a BCP-47 tag`);
320
- }
321
- if (!config.locales.includes(config.defaultLocale)) {
322
- issues.push(`defaultLocale "${config.defaultLocale}" is not in locales`);
323
- }
324
- // `@ultimat3/time`'s rule, restated because tier 0 cannot import tier 1 — see
325
- // `time-zone-name.ts`. One validator means a zone `app.config.ts` accepts is a zone every
326
- // `format` call, `task()` and `toZoned` below it can then do arithmetic in.
327
- if (!isIanaZoneName(config.defaultTimeZone)) {
328
- issues.push(
329
- `defaultTimeZone "${config.defaultTimeZone}" is not an IANA Area/Location zone name`,
330
- );
331
- zoneFix.push(TIMEZONE_FIX);
332
- }
333
- if (!CURRENCY_RE.test(config.defaultCurrency)) {
334
- issues.push(`defaultCurrency "${config.defaultCurrency}" is not a 3-letter ISO 4217 code`);
335
- }
336
268
  if (config.roles.length === 0) issues.push('roles must list at least one runtime role');
337
- // A domain per numeric key, never a bare `< 1`: every comparison with `NaN` is false, so the old
338
- // `concurrency < 1` passed `NaN`, `2.5` and `Infinity`, and nothing screened the other three.
339
- const counts: readonly (string | undefined)[] = [
340
- countIssue('jobs.concurrency', config.jobs.concurrency, 1),
269
+ // ONE check per key, each against the key's own domain. A number is never a bare `< 1` — every
270
+ // comparison with `NaN` is false, so `concurrency < 1` passed `NaN`, `2.5` and `Infinity` — and
271
+ // a switch is never read for truthiness: `ssl: 'false'` and `enabled: 'false'` were both ON.
272
+ const perKey: readonly (string | undefined)[] = [
273
+ ...config.roles.map((role) => oneOfIssue('roles', role, ROLES)),
274
+ ...jobsConcurrencyIssues(config.jobs.concurrency),
341
275
  countIssue('jobs.maxAttempts', config.jobs.maxAttempts, 1),
342
276
  countIssue('jobs.visibilityTimeoutMs', config.jobs.visibilityTimeoutMs, 1),
277
+ oneOfIssue('jobs.backoff', config.jobs.backoff, JOB_BACKOFFS),
343
278
  countIssue('cache.defaultTtlMs', config.cache.defaultTtlMs, 0),
344
- readinessGraceIssue(config.drain.readinessGraceMs),
279
+ oneOfIssue('database.driver', config.database.driver, DATABASE_DRIVERS),
280
+ booleanIssue('database.ssl', config.database.ssl),
281
+ oneOfIssue('theme.defaultMode', config.theme.defaultMode, THEME_MODES),
282
+ // `null` is the documented "no redirect"; a path that is said must be one a browser can follow.
283
+ config.auth.signInPath === null
284
+ ? undefined
285
+ : routePathIssue('auth.signInPath', config.auth.signInPath),
286
+ booleanIssue('ai.mcp.expose', config.ai.mcp.expose),
287
+ booleanIssue('realtime.enabled', config.realtime.enabled),
288
+ oneOfIssue('realtime.transport', config.realtime.transport, REALTIME_TRANSPORTS),
289
+ ...(['maxSubscriptionsPerActor', 'maxSocketsPerActor'] as const).map((key) =>
290
+ config.realtime[key] === undefined
291
+ ? undefined
292
+ : countIssue(`realtime.${key}`, config.realtime[key], 1),
293
+ ),
294
+ ...drainIssues(config.drain),
345
295
  readinessModeIssue(config.health.readiness),
346
296
  ];
347
- for (const issue of counts) if (issue !== undefined) issues.push(issue);
348
- if (config.jobs.queues.length === 0) issues.push('jobs.queues must list at least one queue');
349
- // An untyped config reaches here with whatever it wrote: a string is a name worth echoing, and
350
- // anything else goes through `describeValue` rather than `${…}`.
351
- const transport: unknown = config.realtime.transport;
352
- if (!REALTIME_TRANSPORTS.some((known) => known === transport)) {
353
- const said = typeof transport === 'string' ? `"${transport}"` : describeValue(transport);
354
- issues.push(`realtime.transport ${said} is not one of ${REALTIME_TRANSPORTS.join(', ')}`);
355
- } else if (transport === 'nats' && config.realtime.urlEnv === undefined) {
297
+ for (const issue of perKey) if (issue !== undefined) issues.push(issue);
298
+ nameListIssues('jobs.queues', config.jobs.queues, 'queue', issues);
299
+ if (config.realtime.transport === 'nats' && config.realtime.urlEnv === undefined) {
356
300
  issues.push(`realtime.transport "nats" requires realtime.urlEnv`);
357
301
  }
358
302
  // BOTH RETENTION WINDOWS OR NEITHER — `undefined` is a real value here (never swept) and the
@@ -377,10 +321,14 @@ function validate(config: AppConfig): void {
377
321
  siteIssues(config, issues);
378
322
  navigationIssues(config, issues);
379
323
  islandsIssues(config, issues);
324
+ mailIssues(config, issues);
380
325
 
381
326
  // A rung the ladder cannot build is the defect this key had: `sortTiers` places a name by its
382
327
  // index in `CACHE_TIERS`, and a name missing from it sorts to `-1` — AHEAD of the request memo.
383
328
  // So an unknown tier is refused at boot rather than silently ignored or silently placed first.
329
+ // An EMPTY ladder is refused with the unknown rung: it builds no tier at all, so every read
330
+ // misses and nothing says the cache was configured away.
331
+ if (config.cache.tiers.length === 0) issues.push('cache.tiers must list at least one tier');
384
332
  for (const tier of config.cache.tiers) {
385
333
  if (CACHE_TIERS.includes(tier)) continue;
386
334
  issues.push(`cache.tiers contains "${tier}", which is not one of ${CACHE_TIERS.join(', ')}`);
@@ -392,43 +340,21 @@ function validate(config: AppConfig): void {
392
340
  cause: issues.join('; '),
393
341
  // The generic instruction goes LAST so the fix line still ends in a command that can be
394
342
  // pasted — a trailing `.` after `x verify` is a command nobody can run.
395
- fix: [...zoneFix, ...tierFix, ...pwaFix, BASE_FIX].join('. '),
343
+ fix: [...tierFix, ...pwaFix, BASE_FIX].join('. '),
396
344
  meta: { issues },
397
345
  });
398
346
  }
399
347
  }
400
348
 
401
349
  /**
402
- * The single config entry point. Later overlays win, so `config/jobs.ts` can own jobs without
403
- * touching `app.config.ts`.
350
+ * Every layer, in order, merged per section and KEY BY KEY — see `layered`. `name` comes from the
351
+ * input alone because it identifies the app: an overlay may not rename it. No layer at all is the
352
+ * defaults, which is the reference `shapeIssues` compares each layer against.
404
353
  */
405
- export function defineConfig(
406
- input: AppConfigInput,
407
- ...overlays: readonly AppConfigOverlay[]
408
- ): AppConfig {
409
- const base = defaults(input.name);
410
- // Every layer, in order, merged per section and KEY BY KEY — see `layered`. `name` comes from
411
- // the input alone because it identifies the app: an overlay may not rename it.
412
- const layers: readonly AppConfigOverlay[] = [input, ...overlays];
413
-
414
- const config: AppConfig = {
415
- name: input.name,
416
- locales: lastSaid(
417
- base.locales,
418
- layers.map((layer) => layer.locales),
419
- ),
420
- defaultLocale: lastSaid(
421
- base.defaultLocale,
422
- layers.map((layer) => layer.defaultLocale),
423
- ),
424
- defaultTimeZone: lastSaid(
425
- base.defaultTimeZone,
426
- layers.map((layer) => layer.defaultTimeZone),
427
- ),
428
- defaultCurrency: lastSaid(
429
- base.defaultCurrency,
430
- layers.map((layer) => layer.defaultCurrency),
431
- ),
354
+ function merge(name: string, layers: readonly AppConfigOverlay[]): AppConfig {
355
+ const base = configDefaults(name);
356
+ return {
357
+ name,
432
358
  theme: layered(
433
359
  base.theme,
434
360
  layers.map((layer) => layer.theme),
@@ -491,8 +417,50 @@ export function defineConfig(
491
417
  ...mergeSite(layers),
492
418
  ...mergeNavigation(layers),
493
419
  ...mergeIslands(layers),
420
+ ...mergeMail(layers),
494
421
  };
422
+ }
423
+
424
+ /**
425
+ * FIRST, before shape: a removed key is an instruction the app is still giving, and the answer is
426
+ * the line to delete and what replaces it — never silence, and never a shape complaint about a
427
+ * section (`theme.tokens: null`) that no longer has the key at all. Every layer is asked, so a
428
+ * stale `config/theme.ts` overlay is caught as surely as the base.
429
+ */
430
+ function refuseRemovedKeys(layers: readonly unknown[]): void {
431
+ const removed = [...new Set(layers.flatMap((layer) => removedKeysIn(layer)))];
432
+ if (removed.length === 0) return;
433
+ const issues = removed.map(removedKeyIssue);
434
+ throw new ConfigInvalidError({
435
+ cause: issues.join('; '),
436
+ fix: [...removed.map(removedKeyFix), BASE_FIX].join('. '),
437
+ meta: { issues, removed },
438
+ });
439
+ }
495
440
 
441
+ /**
442
+ * The single config entry point. Later overlays win, so `config/jobs.ts` can own jobs without
443
+ * touching `app.config.ts`.
444
+ */
445
+ export function defineConfig(
446
+ input: AppConfigInput,
447
+ ...overlays: readonly AppConfigOverlay[]
448
+ ): AppConfig {
449
+ const layers: readonly AppConfigOverlay[] = [input, ...overlays];
450
+ refuseRemovedKeys(layers);
451
+ // Structure FIRST, per layer and before the merge: a section written as `null` or a list
452
+ // written as a string is what `Object.entries` and `.length` raised a native `TypeError` on.
453
+ const issues: string[] = [];
454
+ // `navigation` is left out: `config-navigation.ts` carries a wrong shape through AS WRITTEN and
455
+ // refuses it in its own words, with the surfaces it accepts.
456
+ const reference = { ...merge(input.name, []), navigation: undefined };
457
+ // Closed BEFORE shape: a typo'd section (`drian: null`) is the wrong name, not the wrong kind.
458
+ refuseUnknownKeys(reference, layers);
459
+ for (const layer of layers) shapeIssues(reference, layer, issues);
460
+ if (issues.length > 0) {
461
+ throw new ConfigInvalidError({ cause: issues.join('; '), fix: BASE_FIX, meta: { issues } });
462
+ }
463
+ const config = merge(input.name, layers);
496
464
  validate(config);
497
465
  return Object.freeze(config);
498
466
  }
package/src/context.ts CHANGED
@@ -3,9 +3,9 @@
3
3
  // parameters — otherwise every signature in the framework grows a `ctx` argument twice.
4
4
  //
5
5
  // THERE IS EXACTLY ONE ASSERTION IN THIS FILE AND IT IS IRREDUCIBLE (`As of 2026-08-24`). It is
6
- // the `as Ctx` in `createContext`, and it is the LAST one: the second — over `preview` — is gone,
6
+ // the `as Ctx` in `ctxOf`, and it is the LAST one: the second — over `preview` — is gone,
7
7
  // because `CtxFacts` gives that value an honest type, and `@ultimat3/http`'s
8
- // `createRequestContext` now composes this function instead of building a second context beside
8
+ // `requestContext` now composes this function instead of building a second context beside
9
9
  // it, so that package has none at all.
10
10
  //
11
11
  // Why the last one cannot go. `Ctx extends CtxServices`, and `CtxServices` is the seam an app
@@ -20,9 +20,9 @@
20
20
  // Four alternatives were built and measured before this line was kept. Making the augmented half
21
21
  // `Partial<CtxServices>` removes the assertion and turns `ctx.posts` into `PostRepo | undefined`
22
22
  // for every app — true, and a breaking change to the documented seam. Requiring `CtxInit.services`
23
- // to be a `CtxServices` moves the proof to the caller and breaks every internal `createContext()`
23
+ // to be a `CtxServices` moves the proof to the caller and breaks every internal `ctxOf()`
24
24
  // in an app's program, because an app typechecks the framework's sources through its project
25
- // references. A generic `createContext<S>` returns a context no framework caller can pass where a
25
+ // references. A generic `ctxOf<S>` returns a context no framework caller can pass where a
26
26
  // `Ctx` is wanted. And an overload whose implementation signature returns the looser type compiles
27
27
  // only through TypeScript's documented bivariance hole — the same assertion, laundered.
28
28
  //
@@ -35,7 +35,7 @@ import { asyncContext } from './async-context';
35
35
  import { type Clock, systemClock } from './clock';
36
36
  import { UltimateError } from './errors';
37
37
  import { finiteOption } from './finite-option';
38
- import { traceId as newTraceId, uuid } from './ids';
38
+ import { traceId as newTraceId, uuidV7 } from './ids';
39
39
  import { type Logger, logger as rootLogger, setLoggerContextFields } from './logger';
40
40
  import { installTraceHeaders } from './outbound-headers';
41
41
  import { type Role, resolveRole } from './roles';
@@ -73,7 +73,7 @@ export interface ServiceBag {
73
73
  * against a type carrying members only the app's boot knows about: `Ctx extends CtxServices`, an
74
74
  * app augments `CtxServices` with `declare module`, and every service it declares then became a
75
75
  * REQUIRED member of every context literal in the framework. `@ultimat3/http`'s
76
- * `createRequestContext` stopped compiling inside `examples/dummy` for exactly that reason
76
+ * `requestContext` stopped compiling inside `examples/dummy` for exactly that reason
77
77
  * (`TS2739: missing posts, orgs`), while the framework's own gate — which augments nothing —
78
78
  * stayed green.
79
79
  *
@@ -157,6 +157,10 @@ const requestContext = asyncContext<Ctx>('the request context');
157
157
 
158
158
  const neverAborted = new AbortController().signal;
159
159
 
160
+ /**
161
+ * The framework's default locale — the ONE declaration: `@ultimat3/i18n` imports it rather than
162
+ * restating it (a second `'en'` there could drift from the context's own default).
163
+ */
160
164
  export const DEFAULT_LOCALE = 'en';
161
165
  export const DEFAULT_TIME_ZONE = 'UTC';
162
166
 
@@ -164,9 +168,9 @@ function buildId(): string {
164
168
  return process.env['BUILD_ID'] ?? 'dev';
165
169
  }
166
170
 
167
- export function createContext(init: CtxInit = {}): Ctx {
171
+ export function ctxOf(init: CtxInit = {}): Ctx {
168
172
  const clock = init.clock ?? systemClock;
169
- const requestId = init.requestId ?? uuid(clock);
173
+ const requestId = init.requestId ?? uuidV7(clock);
170
174
  const trace = init.traceId ?? newTraceId();
171
175
  const base = init.logger ?? rootLogger;
172
176
  const explicit: ServiceBag = Object.freeze({ ...(init.services ?? {}) });
@@ -233,7 +237,7 @@ export function useContext(): Ctx {
233
237
  throw new UltimateError({
234
238
  code: 'X_NO_CONTEXT',
235
239
  cause: 'useContext() was called outside of runWithContext()',
236
- fix: 'wrap the entry point in runWithContext(createContext({ ... }), fn)',
240
+ fix: 'wrap the entry point in runWithContext(ctxOf({ ... }), fn)',
237
241
  });
238
242
  }
239
243
  return ctx;
@@ -272,6 +276,18 @@ function screenDeadline(value: number | null | undefined): number | null {
272
276
  return finiteOption('the request context', 'deadlineAt', value);
273
277
  }
274
278
 
279
+ /**
280
+ * A patched signal is ADDED to the parent's, never swapped for it — the deadline's own rule, one
281
+ * field over. `patch ?? parent` made a step that brought its own signal blind to the request it
282
+ * runs inside: the client disconnected, the request timed out, and `ctx.signal.aborted` stayed
283
+ * false in the child. The shared never-aborting default is skipped rather than composed, so a
284
+ * context with no request behind it does not grow a listener per child.
285
+ */
286
+ function composeSignal(parent: AbortSignal, patch: AbortSignal | undefined): AbortSignal {
287
+ if (patch === undefined || patch === parent) return parent;
288
+ return parent === neverAborted ? patch : AbortSignal.any([parent, patch]);
289
+ }
290
+
275
291
  /**
276
292
  * Derive a narrowed context — impersonation, a locale switch, a per-step abort signal.
277
293
  * `requestId` is deliberately not patchable: one request, one id.
@@ -279,13 +295,13 @@ function screenDeadline(value: number | null | undefined): number | null {
279
295
  export function withChildContext<T>(patch: CtxPatch, fn: () => T): T {
280
296
  const parent = useContext();
281
297
  // A factory-managed service was built for the PARENT's actor; forwarding it verbatim into an
282
- // impersonated child would answer every call with the parent's tenant. `createContext` below
298
+ // impersonated child would answer every call with the parent's tenant. `ctxOf` below
283
299
  // rebuilds every registered factory fresh against the child's own actor, so only services no
284
300
  // factory owns — a hand-built mock nothing registered — carry forward unrebuilt.
285
301
  const carried = Object.fromEntries(
286
302
  Object.entries(parent.services).filter(([name]) => !isManagedService(name)),
287
303
  );
288
- const child = createContext({
304
+ const child = ctxOf({
289
305
  requestId: parent.requestId,
290
306
  traceId: patch.traceId ?? parent.traceId,
291
307
  actor: patch.actor ?? parent.actor,
@@ -301,7 +317,7 @@ export function withChildContext<T>(patch: CtxPatch, fn: () => T): T {
301
317
  // that and did not do it — a patched hour replaced a parent's second outright, and
302
318
  // `remainingBudgetMs` then put the hour on `x-request-timeout-ms` for the next hop.
303
319
  deadlineAt: earliest(screenDeadline(patch.deadlineAt) ?? undefined, parent.deadlineAt),
304
- signal: patch.signal ?? parent.signal,
320
+ signal: composeSignal(parent.signal, patch.signal),
305
321
  services: { ...carried, ...(patch.services ?? {}) },
306
322
  });
307
323
  return requestContext.run(child, fn);
@@ -318,7 +334,7 @@ export function useService<T>(name: string): T {
318
334
  throw new UltimateError({
319
335
  code: 'X_SERVICE_MISSING',
320
336
  cause: `"${name}" is not on ctx.services (have: ${Object.keys(ctx.services).join(', ')})`,
321
- fix: `pass it in createContext({ services: { ${name} } })`,
337
+ fix: `pass it in ctxOf({ services: { ${name} } })`,
322
338
  meta: { name },
323
339
  });
324
340
  }