@ultimat3/core 24.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 (76) hide show
  1. package/CLAUDE.md +24 -27
  2. package/README.md +71 -34
  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 +18 -11
  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 +1 -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 +0 -33
  29. package/src/config.ts +71 -94
  30. package/src/context.ts +16 -12
  31. package/src/cookie.ts +267 -3
  32. package/src/core-error-codes.ts +2 -0
  33. package/src/cursor-page.ts +41 -0
  34. package/src/cursor.ts +23 -5
  35. package/src/deprecation.ts +77 -0
  36. package/src/dev-secrets.ts +18 -7
  37. package/src/drain-deadline.ts +43 -0
  38. package/src/env-example.ts +9 -29
  39. package/src/errors.ts +18 -9
  40. package/src/exports/error-contract.ts +0 -1
  41. package/src/exports/observability.ts +1 -1
  42. package/src/exports/secrets.ts +3 -0
  43. package/src/finite-option.ts +1 -1
  44. package/src/flight-gate.ts +29 -14
  45. package/src/generation-fence.ts +1 -1
  46. package/src/ids.ts +7 -7
  47. package/src/image/canvas.ts +76 -5
  48. package/src/image/pipeline.ts +17 -5
  49. package/src/image/raster.ts +24 -1
  50. package/src/index.ts +89 -35
  51. package/src/iso-date.ts +1 -1
  52. package/src/lifecycle-errors.ts +1 -1
  53. package/src/lifecycle-readiness.ts +60 -2
  54. package/src/lifecycle-signals.ts +27 -2
  55. package/src/lifecycle-types.ts +96 -0
  56. package/src/lifecycle.ts +44 -133
  57. package/src/locale-direction.ts +1 -1
  58. package/src/logger.ts +16 -7
  59. package/src/mcp-exposure.ts +70 -8
  60. package/src/measurement-actor.ts +16 -1
  61. package/src/metric-errors.ts +32 -0
  62. package/src/metric-registry.ts +151 -0
  63. package/src/metric-series.ts +94 -0
  64. package/src/metrics.ts +9 -255
  65. package/src/page.ts +4 -2
  66. package/src/registrar.ts +1 -0
  67. package/src/retry.ts +25 -5
  68. package/src/secrets-key-file.ts +139 -0
  69. package/src/secrets-store.ts +32 -16
  70. package/src/service.ts +5 -5
  71. package/src/single-flight.ts +1 -1
  72. package/src/telemetry.ts +1 -1
  73. package/src/theme-storage.ts +12 -0
  74. package/src/type-pins.ts +51 -1
  75. package/src/image/fixtures.ts +0 -263
  76. package/src/time-zone-name.ts +0 -14
@@ -6,7 +6,6 @@
6
6
  // were: shape, merge and screen are one subject.
7
7
 
8
8
  import { describeValue } from './error-render';
9
- import { ConfigInvalidError } from './errors';
10
9
 
11
10
  /**
12
11
  * The surfaces that render documents a browser navigates between. `api` answers JSON and `shared`
@@ -73,7 +72,7 @@ export interface NavigationSectionInput {
73
72
  }
74
73
 
75
74
  /**
76
- * Whole-value keys: the last layer that listed surfaces wins, as `locales` does — and so does the
75
+ * Whole-value keys: the last layer that listed surfaces wins, as `roles` does — and so does the
77
76
  * last one that set `speculation.prefetch` or listed `speculation.exclude`, each on its own.
78
77
  */
79
78
  export function mergeNavigation(layers: readonly NavigationSectionInput[]): NavigationSection {
@@ -138,29 +137,6 @@ function speculationIssues(speculation: unknown, issues: string[]): void {
138
137
  }
139
138
  }
140
139
 
141
- /**
142
- * `navigation.speculation` as some reader OUTSIDE `defineConfig` found it (`@ultimat3/cli` imports
143
- * the app's config module structurally): the defaults for what it does not say, and the SAME
144
- * refusal `defineConfig` gives for what it says wrongly. One validator — a second reader that
145
- * coerced `'eager'` to `'moderate'` or dropped a bad pattern would serve rules the app never wrote.
146
- */
147
- export function resolveSpeculation(said: unknown): SpeculationConfig {
148
- if (said === undefined) return DEFAULT_SPECULATION;
149
- const { speculation } = mergeNavigation([
150
- { navigation: { speculation: said as SpeculationInput } },
151
- ]).navigation;
152
- const issues: string[] = [];
153
- speculationIssues(speculation, issues);
154
- if (issues.length > 0) {
155
- throw new ConfigInvalidError({
156
- cause: issues.join('; '),
157
- fix: 'Correct navigation.speculation in app.config.ts: prefetch is "moderate", "conservative" or false, and exclude is a list of path patterns starting with "/"',
158
- meta: { issues },
159
- });
160
- }
161
- return speculation;
162
- }
163
-
164
140
  /** Appends every refusal the section earns to `issues`, `config.ts`' one list. */
165
141
  export function navigationIssues(config: NavigationSection, issues: string[]): void {
166
142
  speculationIssues(config.navigation.speculation, issues);
package/src/config-pwa.ts CHANGED
@@ -13,7 +13,7 @@
13
13
  import { describeValue } from './error-render';
14
14
 
15
15
  /**
16
- * `installPrompt` was removed 2026-08, same rule: `@ultimat3/pwa`'s `createInstallController` is
16
+ * `installPrompt` was removed 2026-08, same rule: `@ultimat3/pwa`'s `installController` is
17
17
  * real and complete, nothing ever threaded the flag into it, and both tracked apps plus every
18
18
  * scaffolded app set a switch with no wire. Call the controller from your own affordance instead.
19
19
  */
@@ -115,8 +115,8 @@ export interface PwaScreenshot {
115
115
  /**
116
116
  * The install chrome's two colours for one scheme, as CSS colour strings.
117
117
  *
118
- * ONE OF THE TWO PLACES A RAW COLOUR IS LEGAL, alongside `ThemeConfig.tokens` one section up, and
119
- * for a stronger reason than that one has: a browser paints the install splash and the address bar
118
+ * THE ONE PLACE IN `app.config.ts` A RAW COLOUR IS LEGAL (`theme.tokens`, the other, was deleted in
119
+ * 25.0.0 — the theme is `defineTheme` in `@ultimat3/ui`), because a browser paints the install splash and the address bar
120
120
  * from these before a single stylesheet has loaded, so there is no token to resolve them against
121
121
  * and no component anywhere in the loop.
122
122
  */
@@ -173,10 +173,20 @@ export function pwaIssues(pwa: PwaConfig, issues: string[]): boolean {
173
173
  // `pwa.enabled` means an installable app, and an installable app that shows the browser's error
174
174
  // page offline is the failure the whole block exists to prevent, so this is required rather
175
175
  // than optional: the alternative is two meanings for one switch (axiom 1).
176
+ // Same origin, too: `//host/offline` and `/\host/offline` start with `/` and are still another
177
+ // host, which the worker would precache and serve as this app's offline page.
176
178
  const fallback: unknown = pwa.offline?.fallback;
177
- if (typeof fallback !== 'string' || !fallback.startsWith('/')) {
179
+ if (!isSameOriginPath(fallback)) {
178
180
  issues.push(
179
- `pwa.offline.fallback is required when pwa.enabled is true and must be an absolute route path like "/offline", and is ${describeValue(fallback)}`,
181
+ `pwa.offline.fallback is required when pwa.enabled is true and must be an absolute route path on this origin like "/offline", and is ${describeValue(fallback)}`,
182
+ );
183
+ }
184
+ for (const key of ['image', 'font'] as const) {
185
+ const placeholder: unknown = pwa.offline?.[key];
186
+ if (placeholder === undefined || placeholder === null || isSameOriginPath(placeholder))
187
+ continue;
188
+ issues.push(
189
+ `pwa.offline.${key} must be a path on this origin like "/offline.svg", and is ${describeValue(placeholder)}`,
180
190
  );
181
191
  }
182
192
  const personal: unknown = pwa.offline?.personalPages;
@@ -233,3 +243,30 @@ function manifestIssues(pwa: PwaConfig, issues: string[]): void {
233
243
  }
234
244
  }
235
245
  }
246
+
247
+ /** An origin no relative path can reach: a value that resolves anywhere else left this origin. */
248
+ const PROBE_ORIGIN = 'http://x.invalid';
249
+
250
+ /**
251
+ * A path on THIS origin, as a browser will resolve it — the one predicate for every URL the worker
252
+ * precaches and serves as an offline answer (`@ultimat3/pwa`'s build asks the same question).
253
+ * Judged by RESOLUTION, never by the raw prefix: the URL parser strips a tab, CR or LF anywhere and
254
+ * reads `\` as `/`, so `/\t/evil.test/x` is `//evil.test/x` once parsed. Every C0 control and DEL
255
+ * is refused before the parse, as `@ultimat3/http`'s sign-in redirect does; a dot segment that
256
+ * leaves a `//` pathname (`/.//evil.test`) is refused after it, since any reader without the base
257
+ * takes that pathname as a host.
258
+ */
259
+ export function isSameOriginPath(value: unknown): value is string {
260
+ if (typeof value !== 'string' || !value.startsWith('/')) return false;
261
+ for (let index = 0; index < value.length; index += 1) {
262
+ const code = value.charCodeAt(index);
263
+ if (code < 0x20 || code === 0x7f) return false;
264
+ }
265
+ let resolved: URL;
266
+ try {
267
+ resolved = new URL(value, PROBE_ORIGIN);
268
+ } catch {
269
+ return false;
270
+ }
271
+ return resolved.origin === PROBE_ORIGIN && !resolved.pathname.startsWith('//');
272
+ }
@@ -0,0 +1,131 @@
1
+ // Single responsibility: the `app.config.ts` keys a major DELETED, and the refusal an app still
2
+ // writing one gets. A deleted key the validator ignores is a switch with no wire — an operator sets
3
+ // it, redeploys and nothing changes — so each is refused by name, with the line that replaces it.
4
+
5
+ import { isJsonObject } from './json-object';
6
+
7
+ export interface RemovedConfigKey {
8
+ /** The major that deleted it. */
9
+ readonly removedIn: string;
10
+ /** What an app writes instead — the second half of the `fix:`. */
11
+ readonly instead: string;
12
+ }
13
+
14
+ /**
15
+ * Dotted paths, one row per deleted leaf. Every row older than 25.0.0 was silently carried through
16
+ * `section()` until 25.0.0 refused it — this table by name, then `config-keys.ts` closing the
17
+ * shape for any other key; a row is what turns "not a key" into the line that replaces it. A row
18
+ * is never removed: a config written against an older major must keep getting the instruction,
19
+ * not silence. `Object.freeze`d, read through `Object.hasOwn`, so `__proto__` in a layer names no
20
+ * row.
21
+ */
22
+ export const REMOVED_CONFIG_KEYS: Readonly<Record<string, RemovedConfigKey>> = Object.freeze({
23
+ locales: {
24
+ removedIn: '25.0.0',
25
+ instead:
26
+ "the app's locales are the keys of its catalogs: defineCatalogs({ default: 'en', locales: { en, es } }) from @ultimat3/i18n, in packages/i18n/src/index.ts",
27
+ },
28
+ defaultLocale: {
29
+ removedIn: '25.0.0',
30
+ instead:
31
+ "the fallback locale is defineCatalogs({ default: 'en', locales: { en } })'s default, from @ultimat3/i18n",
32
+ },
33
+ defaultTimeZone: {
34
+ removedIn: '25.0.0',
35
+ instead:
36
+ "nothing read it; there is no ambient zone — pass one at every call: formatDate(at, { locale, zone: 'Europe/Paris' }), task({ tz }), userActor({ tz })",
37
+ },
38
+ defaultCurrency: {
39
+ removedIn: '25.0.0',
40
+ instead:
41
+ "nothing read it; every Money carries its own currency ({ minor, currency: 'USD' }) — an app that wants a default declares its own constant in an app module",
42
+ },
43
+ 'jobs.driver': {
44
+ removedIn: '5.0.0',
45
+ instead:
46
+ 'nothing read it and boot always built Postgres; the driver is code — setJobDriver(postgresJobDriver({ executor })) from @ultimat3/jobs, or setJobDriver(memoryJobDriver()) in a test',
47
+ },
48
+ 'realtime.heartbeatMs': {
49
+ removedIn: '4.0.0',
50
+ instead:
51
+ "nothing read it; the page socket's beat is the sync node's, named in its hello reply, and the presence beat is a third of the presence ttl",
52
+ },
53
+ 'database.urlEnv': {
54
+ removedIn: '4.0.0',
55
+ instead:
56
+ 'nothing read it; the connection string is the DATABASE_URL environment variable, read by @ultimat3/db',
57
+ },
58
+ 'database.poolSize': {
59
+ removedIn: '4.0.0',
60
+ instead:
61
+ 'nothing read it; the pool is sized by the DATABASE_POOL_MAX environment variable, per process',
62
+ },
63
+ 'database.schema': {
64
+ removedIn: '4.0.0',
65
+ instead:
66
+ 'nothing read it and nothing emits SET search_path; entity() tables live in public, and there is no replacement',
67
+ },
68
+ 'pwa.installPrompt': {
69
+ removedIn: '8.0.0',
70
+ instead:
71
+ 'nothing read it; call installController() from @ultimat3/pwa in your own install affordance',
72
+ },
73
+ 'auth.afterSignInPath': {
74
+ removedIn: '8.0.0',
75
+ instead:
76
+ 'nothing read it; send the visitor where you mean from your sign-in route, the only code that can honour it',
77
+ },
78
+ 'ai.modelEnv': {
79
+ removedIn: '8.0.0',
80
+ instead:
81
+ "nothing read it; the model is the prompt's own, or llm({ model }) from @ultimat3/ai — read your own env key and pass it there",
82
+ },
83
+ 'cache.driver': {
84
+ removedIn: '9.0.0',
85
+ instead:
86
+ "cache.tiers is the one selector — name the rung: cache: { tiers: ['request-memo', 'lru', 'redis'] }",
87
+ },
88
+ 'cache.urlEnv': {
89
+ removedIn: '9.0.0',
90
+ instead:
91
+ "nothing read it; the redis tier reads the REDIS_URL environment variable, so name 'redis' in cache.tiers and set REDIS_URL",
92
+ },
93
+ 'realtime.tier': {
94
+ removedIn: '10.0.0',
95
+ instead:
96
+ "nothing read it; an app's realtime tier is what it declares — a channel() topic, a live: true query, persist: true on an entity",
97
+ },
98
+ 'theme.tokens': {
99
+ removedIn: '25.0.0',
100
+ instead:
101
+ "nothing read it; the app's theme is declared once with export const brand = defineTheme({ … }) from @ultimat3/ui, in apps/web/shared/theme.ts",
102
+ },
103
+ 'ai.mcp.path': {
104
+ removedIn: '25.0.0',
105
+ instead:
106
+ "an MCP endpoint's path is its own: defineAppMcp({ path: '/mcp' }) from @ultimat3/mcp, in apps/<app>/mcp.ts — the default is /mcp, and every endpoint mounts where its own metadata says",
107
+ },
108
+ });
109
+
110
+ const said = (layer: unknown, path: string): boolean => {
111
+ let at: unknown = layer;
112
+ for (const segment of path.split('.')) {
113
+ if (!isJsonObject(at) || !Object.hasOwn(at, segment)) return false;
114
+ at = at[segment];
115
+ }
116
+ // `undefined` is a layer not saying, the rule every other key follows.
117
+ return at !== undefined;
118
+ };
119
+
120
+ /** The removed keys one layer still writes, in table order. */
121
+ export function removedKeysIn(layer: unknown): readonly string[] {
122
+ return Object.keys(REMOVED_CONFIG_KEYS).filter((path) => said(layer, path));
123
+ }
124
+
125
+ /** One cause line per removed key, naming the major. */
126
+ export const removedKeyIssue = (path: string): string =>
127
+ `${path} was removed in ${REMOVED_CONFIG_KEYS[path]?.removedIn ?? 'a major'} and is no longer read`;
128
+
129
+ /** One instruction per removed key: delete the line, and what replaces it. */
130
+ export const removedKeyFix = (path: string): string =>
131
+ `delete ${path} from app.config.ts — ${REMOVED_CONFIG_KEYS[path]?.instead ?? ''}`;
@@ -77,36 +77,3 @@ export function nameListIssues(
77
77
  }
78
78
  }
79
79
  }
80
-
81
- function canonicalTag(tag: unknown): string | undefined {
82
- if (typeof tag !== 'string') return undefined;
83
- try {
84
- const canonical = Intl.getCanonicalLocales(tag);
85
- return canonical.length === 1 ? canonical[0] : undefined;
86
- } catch {
87
- return undefined;
88
- }
89
- }
90
-
91
- /**
92
- * The locale list and the tag that must be in it. Two spellings of ONE locale are refused: the
93
- * list keys a catalog, a route prefix and an `hreflang` each, and `['EN', 'en']` is two of every
94
- * one of them for a single language.
95
- */
96
- export function localeIssues(tags: readonly unknown[], fallback: unknown, issues: string[]): void {
97
- if (tags.length === 0) issues.push('locales must list at least one locale');
98
- const seen = new Map<string, unknown>();
99
- for (const tag of tags) {
100
- const canonical = canonicalTag(tag);
101
- if (canonical === undefined) {
102
- issues.push(`locales contains ${said(tag)}, not a BCP-47 tag`);
103
- } else if (seen.has(canonical)) {
104
- issues.push(
105
- `locales lists ${canonical} twice, as ${said(seen.get(canonical))} and ${said(tag)}`,
106
- );
107
- } else {
108
- seen.set(canonical, tag);
109
- }
110
- }
111
- if (!tags.includes(fallback)) issues.push(`defaultLocale ${said(fallback)} is not in locales`);
112
- }
package/src/config.ts CHANGED
@@ -2,26 +2,28 @@
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
11
  import { configDefaults } from './config-defaults';
12
- import { BASE_FIX, CACHE_TIER_FIX, TIMEZONE_FIX } from './config-fixes';
13
- import type { DrainConfig, HealthConfig } from './config-health';
14
- import { readinessModeIssue } from './config-health';
12
+ import { BASE_FIX, CACHE_TIER_FIX } from './config-fixes';
13
+ import { type DrainConfig, type HealthConfig, readinessModeIssue } from './config-health';
15
14
  import type { IslandsConfig, IslandsSectionInput } from './config-islands';
16
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';
17
19
  import { type Input, lastSaid, layered } from './config-merge';
18
20
  import type { NavigationConfig, NavigationSectionInput } from './config-navigation';
19
21
  import { mergeNavigation, navigationIssues } from './config-navigation';
20
22
  import type { PwaConfig, PwaOfflineConfig } from './config-pwa';
21
23
  import { PWA_FIX, pwaIssues } from './config-pwa';
24
+ import { removedKeyFix, removedKeyIssue, removedKeysIn } from './config-removed';
22
25
  import {
23
26
  booleanIssue,
24
- localeIssues,
25
27
  nameListIssues,
26
28
  oneOfIssue,
27
29
  routePathIssue,
@@ -29,11 +31,10 @@ import {
29
31
  } from './config-shape';
30
32
  import type { SeoConfig, SiteConfig, SiteSectionsInput } from './config-site';
31
33
  import { mergeSite, siteIssues } from './config-site';
34
+ import { drainIssues } from './drain-deadline';
32
35
  import { describeValue } from './error-render';
33
36
  import { ConfigInvalidError } from './errors';
34
- import { readinessGraceIssue } from './lifecycle-grace';
35
37
  import { ROLES, type Role } from './roles';
36
- import { isIanaZoneName } from './time-zone-name';
37
38
 
38
39
  export const THEME_MODES = ['light', 'dark', 'system'] as const;
39
40
  export type ThemeMode = (typeof THEME_MODES)[number];
@@ -44,10 +45,12 @@ export type ThemeMode = (typeof THEME_MODES)[number];
44
45
  export const REALTIME_TRANSPORTS = ['memory', 'nats'] as const;
45
46
  export type RealtimeTransport = (typeof REALTIME_TRANSPORTS)[number];
46
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
+ */
47
52
  export interface ThemeConfig {
48
53
  readonly defaultMode: ThemeMode;
49
- /** Semantic design tokens. Raw hex is a lint error in components, never here. */
50
- readonly tokens: Readonly<Record<string, string>>;
51
54
  }
52
55
 
53
56
  /**
@@ -133,7 +136,7 @@ export interface DatabaseConfig {
133
136
  */
134
137
  export interface CacheConfig {
135
138
  readonly defaultTtlMs: number;
136
- /** 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. */
137
140
  readonly tiers: readonly CacheTierName[];
138
141
  }
139
142
 
@@ -142,17 +145,21 @@ const JOB_BACKOFFS = ['exponential', 'fixed'] as const;
142
145
  export interface JobsConfig {
143
146
  /**
144
147
  * No `driver`. It accepted `'postgres' | 'redis' | 'nats'`, was read by NOTHING, and boot always
145
- * built `createPgDriver` — so `jobs: { driver: 'redis' }` did not throw, did not warn, and
146
- * silently gave you Postgres. Deleted 2026-08-20, and it is the worse shape of the same defect
147
- * `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.
148
151
  *
149
- * The seam that works is `setJobDriver(driver)` — `setJobDriver(createPgDriver({ executor }))`,
150
- * or `setJobDriver(createMemoryDriver())` in a test. Swap the driver, zero job-code change, which
151
- * is the whole of what the `JobDriver` interface buys. There is no config line, and one that
152
- * 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.
153
156
  */
154
157
  readonly queues: readonly string[];
155
- 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;
156
163
  readonly maxAttempts: number;
157
164
  readonly backoff: (typeof JOB_BACKOFFS)[number];
158
165
  readonly visibilityTimeoutMs: number;
@@ -167,37 +174,28 @@ export interface JobsConfig {
167
174
  *
168
175
  * No `tier` either (deleted 2026-08-23): no file read it, so `tier: 'local-first'` bought nothing.
169
176
  * An app's realtime tier is what it DECLARES — a `channel()` topic, a `live: true` query, a local
170
- * 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.
171
179
  */
172
180
  export interface RealtimeConfig {
173
181
  readonly enabled: boolean;
174
182
  readonly transport: RealtimeTransport;
175
183
  readonly urlEnv: string | undefined;
176
- }
177
-
178
- export interface McpConfig {
179
- readonly expose: boolean;
180
- 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;
181
188
  }
182
189
 
183
190
  /**
184
- * No `modelEnv`. It named the env KEY holding the model id, "so no model string is baked into the
185
- * image" — and its only reader was this file's own merge, copying input to output. Nothing
186
- * consumed the merged value, so `modelEnv: 'ANTHROPIC_MODEL'` selected no model: `@ultimat3/ai`
187
- * reads env for API KEYS only, and the model is `request.model ?? DEFAULT_MODEL`, a compile-time
188
- * constant in `models.ts`. The exact thing the key existed to prevent is what it delivered.
189
- * 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).
190
196
  */
191
- export interface AiConfig {
192
- readonly mcp: McpConfig;
193
- }
194
-
195
197
  export interface AppConfig {
196
198
  readonly name: string;
197
- readonly locales: readonly string[];
198
- readonly defaultLocale: string;
199
- readonly defaultTimeZone: string;
200
- readonly defaultCurrency: string;
201
199
  readonly theme: ThemeConfig;
202
200
  readonly auth: AuthConfig;
203
201
  readonly pwa: PwaConfig;
@@ -214,11 +212,7 @@ export interface AppConfig {
214
212
  readonly seo: SeoConfig;
215
213
  readonly navigation: NavigationConfig;
216
214
  readonly islands: IslandsConfig;
217
- }
218
-
219
- /** `mcp` is the only member, and it is NESTED — `Input<AiConfig>` would make it all-or-nothing. */
220
- export interface AiConfigInput {
221
- readonly mcp?: Input<McpConfig> | undefined;
215
+ readonly mail: MailConfig;
222
216
  }
223
217
 
224
218
  /**
@@ -235,12 +229,9 @@ export interface PwaConfigInput extends Omit<Input<PwaConfig>, 'offline'> {
235
229
  export interface AppConfigInput
236
230
  extends SiteSectionsInput,
237
231
  NavigationSectionInput,
238
- IslandsSectionInput {
232
+ IslandsSectionInput,
233
+ MailSectionInput {
239
234
  readonly name: string;
240
- readonly locales?: readonly string[] | undefined;
241
- readonly defaultLocale?: string | undefined;
242
- readonly defaultTimeZone?: string | undefined;
243
- readonly defaultCurrency?: string | undefined;
244
235
  readonly theme?: Input<ThemeConfig> | undefined;
245
236
  readonly auth?: Input<AuthConfig> | undefined;
246
237
  readonly pwa?: PwaConfigInput | undefined;
@@ -260,21 +251,10 @@ export type AppConfigOverlay = Omit<AppConfigInput, 'name'> & { readonly name?:
260
251
 
261
252
  const NAME_RE = /^[a-z][a-z0-9-]{1,63}$/;
262
253
 
263
- /**
264
- * Built from `@ultimat3/schema`'s `CURRENCY_CODE_PATTERN`, the framework's ONE declaration of what
265
- * an ISO 4217 code looks like — the same source `isCurrencyCode`, the published OpenAPI `pattern`
266
- * and `@ultimat3/entity`'s Postgres CHECK all derive from. It was a character-for-character copy
267
- * here until the `core -> schema` edge was declared (`scripts/lib/tiers.ts`), held equal only by a
268
- * pin test in `@ultimat3/cli`.
269
- */
270
- const CURRENCY_RE = new RegExp(CURRENCY_CODE_PATTERN);
271
-
272
254
  function validate(config: AppConfig): void {
273
255
  const issues: string[] = [];
274
- // Zero or one entry: the zone's own remedy, carried only when the zone is what failed.
275
- const zoneFix: string[] = [];
276
- // Same shape, and it exists for the upgrade: an 8.0.0 app carrying `['memo', 'shared']` in an
277
- // 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.
278
258
  const tierFix: string[] = [];
279
259
  // Same shape again: carried only when an installable app is missing what an install needs.
280
260
  const pwaFix: string[] = [];
@@ -285,26 +265,13 @@ function validate(config: AppConfig): void {
285
265
  } else if (!NAME_RE.test(config.name)) {
286
266
  issues.push(`name "${config.name}" must match ${String(NAME_RE)}`);
287
267
  }
288
- localeIssues(config.locales, config.defaultLocale, issues);
289
- // `@ultimat3/time`'s rule, restated because tier 0 cannot import tier 1 — see
290
- // `time-zone-name.ts`. One validator means a zone `app.config.ts` accepts is a zone every
291
- // `format` call, `task()` and `toZoned` below it can then do arithmetic in.
292
- if (!isIanaZoneName(config.defaultTimeZone)) {
293
- issues.push(
294
- `defaultTimeZone "${config.defaultTimeZone}" is not an IANA Area/Location zone name`,
295
- );
296
- zoneFix.push(TIMEZONE_FIX);
297
- }
298
- if (!CURRENCY_RE.test(config.defaultCurrency)) {
299
- issues.push(`defaultCurrency "${config.defaultCurrency}" is not a 3-letter ISO 4217 code`);
300
- }
301
268
  if (config.roles.length === 0) issues.push('roles must list at least one runtime role');
302
269
  // ONE check per key, each against the key's own domain. A number is never a bare `< 1` — every
303
270
  // comparison with `NaN` is false, so `concurrency < 1` passed `NaN`, `2.5` and `Infinity` — and
304
271
  // a switch is never read for truthiness: `ssl: 'false'` and `enabled: 'false'` were both ON.
305
272
  const perKey: readonly (string | undefined)[] = [
306
273
  ...config.roles.map((role) => oneOfIssue('roles', role, ROLES)),
307
- countIssue('jobs.concurrency', config.jobs.concurrency, 1),
274
+ ...jobsConcurrencyIssues(config.jobs.concurrency),
308
275
  countIssue('jobs.maxAttempts', config.jobs.maxAttempts, 1),
309
276
  countIssue('jobs.visibilityTimeoutMs', config.jobs.visibilityTimeoutMs, 1),
310
277
  oneOfIssue('jobs.backoff', config.jobs.backoff, JOB_BACKOFFS),
@@ -317,10 +284,14 @@ function validate(config: AppConfig): void {
317
284
  ? undefined
318
285
  : routePathIssue('auth.signInPath', config.auth.signInPath),
319
286
  booleanIssue('ai.mcp.expose', config.ai.mcp.expose),
320
- routePathIssue('ai.mcp.path', config.ai.mcp.path),
321
287
  booleanIssue('realtime.enabled', config.realtime.enabled),
322
288
  oneOfIssue('realtime.transport', config.realtime.transport, REALTIME_TRANSPORTS),
323
- readinessGraceIssue(config.drain.readinessGraceMs),
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),
324
295
  readinessModeIssue(config.health.readiness),
325
296
  ];
326
297
  for (const issue of perKey) if (issue !== undefined) issues.push(issue);
@@ -350,6 +321,7 @@ function validate(config: AppConfig): void {
350
321
  siteIssues(config, issues);
351
322
  navigationIssues(config, issues);
352
323
  islandsIssues(config, issues);
324
+ mailIssues(config, issues);
353
325
 
354
326
  // A rung the ladder cannot build is the defect this key had: `sortTiers` places a name by its
355
327
  // index in `CACHE_TIERS`, and a name missing from it sorts to `-1` — AHEAD of the request memo.
@@ -368,7 +340,7 @@ function validate(config: AppConfig): void {
368
340
  cause: issues.join('; '),
369
341
  // The generic instruction goes LAST so the fix line still ends in a command that can be
370
342
  // pasted — a trailing `.` after `x verify` is a command nobody can run.
371
- fix: [...zoneFix, ...tierFix, ...pwaFix, BASE_FIX].join('. '),
343
+ fix: [...tierFix, ...pwaFix, BASE_FIX].join('. '),
372
344
  meta: { issues },
373
345
  });
374
346
  }
@@ -383,22 +355,6 @@ function merge(name: string, layers: readonly AppConfigOverlay[]): AppConfig {
383
355
  const base = configDefaults(name);
384
356
  return {
385
357
  name,
386
- locales: lastSaid(
387
- base.locales,
388
- layers.map((layer) => layer.locales),
389
- ),
390
- defaultLocale: lastSaid(
391
- base.defaultLocale,
392
- layers.map((layer) => layer.defaultLocale),
393
- ),
394
- defaultTimeZone: lastSaid(
395
- base.defaultTimeZone,
396
- layers.map((layer) => layer.defaultTimeZone),
397
- ),
398
- defaultCurrency: lastSaid(
399
- base.defaultCurrency,
400
- layers.map((layer) => layer.defaultCurrency),
401
- ),
402
358
  theme: layered(
403
359
  base.theme,
404
360
  layers.map((layer) => layer.theme),
@@ -461,9 +417,27 @@ function merge(name: string, layers: readonly AppConfigOverlay[]): AppConfig {
461
417
  ...mergeSite(layers),
462
418
  ...mergeNavigation(layers),
463
419
  ...mergeIslands(layers),
420
+ ...mergeMail(layers),
464
421
  };
465
422
  }
466
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
+ }
440
+
467
441
  /**
468
442
  * The single config entry point. Later overlays win, so `config/jobs.ts` can own jobs without
469
443
  * touching `app.config.ts`.
@@ -473,12 +447,15 @@ export function defineConfig(
473
447
  ...overlays: readonly AppConfigOverlay[]
474
448
  ): AppConfig {
475
449
  const layers: readonly AppConfigOverlay[] = [input, ...overlays];
450
+ refuseRemovedKeys(layers);
476
451
  // Structure FIRST, per layer and before the merge: a section written as `null` or a list
477
452
  // written as a string is what `Object.entries` and `.length` raised a native `TypeError` on.
478
453
  const issues: string[] = [];
479
454
  // `navigation` is left out: `config-navigation.ts` carries a wrong shape through AS WRITTEN and
480
455
  // refuses it in its own words, with the surfaces it accepts.
481
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);
482
459
  for (const layer of layers) shapeIssues(reference, layer, issues);
483
460
  if (issues.length > 0) {
484
461
  throw new ConfigInvalidError({ cause: issues.join('; '), fix: BASE_FIX, meta: { issues } });