@ultimat3/core 17.0.0 → 18.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.
package/CLAUDE.md CHANGED
@@ -77,17 +77,28 @@ It is not a general sanitiser: a cause is prose and keeps its quotes,
77
77
  its backslashes and its percent signs — only the control range is touched. Line breaks are the
78
78
  structural half; the rest of C0 and DEL ride along because a terminal reads a raw `\u001b` as an ANSI
79
79
  escape, so a cause could repaint the screen or hide the line above it. `@ultimat3/schema` carries a
80
- deliberate duplicate for the tier-0 reason below, pinned behaviourally by
81
- `single-line-pin.test.ts` in `@ultimat3/cli`.
82
-
83
- `describeValue` in `error-render.ts` is a deliberate duplicate of `describeValue` in
84
- `packages/schema/src/describe-value.ts`, for the same tier-0 reason `SCHEMA_ERROR_CODE_TITLES` is
85
- one: schema and core are both tier 0 and `core → schema` is **not** a declared edge in
86
- `scripts/lib/tiers.ts`, so neither may import the other. Keep the two ANSWERING identically — that
87
- is the contract, and the source is no longer character-for-character: schema counts characters
88
- through `char-count.ts`, which core copies privately. A pin test in
89
- `@ultimat3/cli` (which may legally import both) is the mechanical half, the same shape as
90
- `schema-error-codes-pin.test.ts`. **A string's length is CODE POINTS in both, `As of 2026-08-22`**
80
+ deliberate duplicate — `schema -> core` stays forbidden, so that direction cannot be collapsed —
81
+ pinned behaviourally by `single-line-pin.test.ts`, which lives HERE `As of 2026-08-27`, at the tier
82
+ the invariant belongs to, and pins `ERROR_DOCS_URL` and the brand key beside it.
83
+
84
+ **`core -> schema` is a declared edge `As of 2026-08-27`, and FIVE copies went with it.**
85
+ `describeValue`, `charCount`, `CURRENCY_CODE_PATTERN`, `SCHEMA_ERROR_CODES` and `isIanaZoneName`
86
+ were all restated here because both packages are tier 0 and neither could import the other; they
87
+ were held equal by 394 lines of pin test in `@ultimat3/cli`, a TIER-5 package pinning a tier-0
88
+ invariant that no rule required to exist. `describeValue` is the one that made it urgent — it
89
+ prints INSTEAD of a rejected password, so the safety property of the framework's most
90
+ security-sensitive renderer rested on a 63-line behavioural pin at tier 5. `error-render.ts`
91
+ re-exports schema's now, and the four pin files are deleted.
92
+
93
+ The edge cost was MEASURED before it was declared, because axiom 6 makes it a measurement and not
94
+ an argument — `docs/architecture/01-package-map.md` carries the table. Short version: the edge
95
+ alone TRIPLED a core-only browser chunk (6,362 → 19,018 B), because importing schema's barrel with
96
+ no `sideEffects` field forces a bundler to keep every module it reaches; `@ultimat3/schema` now
97
+ declares `sideEffects: false`, which `bun run side-effects` had already measured as true of it, and
98
+ the cost drops to ~1 kB — while `moneyText` from `@ultimat3/ui`, which always carried schema, comes
99
+ out 7.8 kB SMALLER.
100
+
101
+ **A string's length is CODE POINTS, `As of 2026-08-22`**
91
102
  — `validators.ts` rejects in that unit and `json-schema.ts` publishes `minLength` in it, so
92
103
  `.length` made `t.string.min(3).safeParse('👍a')` say "at least 3 chars, received a string of 3
93
104
  characters". The rule it enforces: a `cause` reaches the log index AND the
@@ -125,7 +136,7 @@ shape against a locally declared sample interface for exactly that reason.
125
136
  | the five above, composed into one typed-client call | `client-flight.ts` + `client-wire.ts` | `@ultimat3/action` and `@ultimat3/query` both project a typed client and are both tier 3, so neither could import the other: it shipped as a byte-identical 288-line + 85-line copy in each, policed by a `client-twin.test.ts` in both. Both packages re-export these names, so their public surface is unchanged. Declares NO code of its own — `X_SUPERSEDED`, `X_TIMEOUT` and `X_FLIGHT_GATE_OVERLOADED` are already here |
126
137
  | is this `unknown` a keyed record? | `json-object.ts` | `isJsonObject`, which was the same three terms in both those packages' `stable.ts`. A `Date` and a class instance PASS: it narrows a shape, it does not certify provenance |
127
138
  | a value that must not be printed | `secret.ts` | redacted by VALUE; `revealSecret()` is the one way out, on purpose greppable |
128
- | an `Intl` formatter cache, and the screen in front of it | `intl-cache.ts` (`cachedFormatter`, `canonicalLocale`, `assertLocale`, `MAX_CACHED_FORMATTERS`) | a locale and a zone arrive from a request header, so the tag must be REFUSED when it is not a tag (`X_LOCALE_INVALID`), the key must be canonical AND the cache bounded — never a second copy of any of the three |
139
+ | an `Intl` formatter cache, and the screen in front of it | `intl-cache.ts` (`cachedFormatter`, `canonicalLocale`, `assertLocale`, `MAX_CACHED_FORMATTERS`, `MAX_LOCALE_EXCERPT`) | a locale and a zone arrive from a request header, so the tag must be REFUSED when it is not a tag (`X_LOCALE_INVALID`), the key must be canonical AND the cache bounded — never a second copy of any of the three. The refusal is bounded too: the `cause` quotes back at most `MAX_LOCALE_EXCERPT` (35, RFC 5646 §4.4.1) code points and says so when it cut, and the whole tag rides in `meta.locale` — a `cause` reaches the 400 body and the log line, where a value with no key has nothing a redactor can address |
129
140
  | the committed encrypted values | `secrets.ts` (envelope) + `secrets-store.ts` (files, `installSecrets`) | plaintext is a flat map of ENV NAMES; there is no `secrets.get()` |
130
141
 
131
142
  `installSecrets()` is the ONLY path from `secrets.enc.json` to an app value, and it lands in
@@ -162,12 +173,14 @@ the master key) purely so *wrong key* and *edited file* are two codes and not on
162
173
  cannot tell them apart.
163
174
 
164
175
  `schema-error-codes.ts` is the same shape a second time, for codes this package does not even own.
165
- `@ultimat3/schema` is tier 0 like `core` and so can neither call `registerErrorCodes()` itself nor
166
- import core to reach it — the four codes' titles are a deliberate, tested duplicate of
167
- `SCHEMA_ERROR_CODES` in `packages/schema/src/errors.ts`, registered unconditionally at import time
168
- so any process that imports core (not just `@ultimat3/cli`, which used to be the only registrant)
169
- renders schema's real titles. Neither tier-0 package can check the duplicate against its source, so
170
- the pin (`schema-error-codes-pin.test.ts`) lives in `@ultimat3/cli`, which may legally import both.
176
+ `@ultimat3/schema` is tier 0 like `core` and cannot call `registerErrorCodes()` itself — that would
177
+ be `schema -> core`, which stays forbidden. So core READS `SCHEMA_ERROR_CODES` over the declared
178
+ edge and registers it unconditionally at import time, and any process that imports core (not just
179
+ `@ultimat3/cli`, which used to be the only registrant) renders schema's real titles. The retry
180
+ classification is derived from the same set rather than typed out beside it: a fifth code would
181
+ have been silently absent from a hand-written list, and an unregistered code reads as UNCLASSIFIED,
182
+ so a schema refusal inside a job body burns the whole retry policy re-proving an answer no attempt
183
+ can change.
171
184
 
172
185
  `timing-safe-equal.ts` holds the one constant-time string comparison `@ultimat3/auth` and
173
186
  `@ultimat3/storage` both need — core is the lowest tier both can reach, so the shared code lives
package/README.md CHANGED
@@ -30,6 +30,7 @@ Zero dependencies, zero `@ultimat3/*` imports.
30
30
  | the committed encrypted secrets envelope, AES-256-GCM | `secrets.ts` |
31
31
  | the two secrets files, and decrypted values → `defineEnv` | `secrets-store.ts` |
32
32
  | `defineConfig()` for `app.config.ts` | `config.ts` |
33
+ | the `pwa` block — what an install needs, and the boot refusal when it is not there | `config-pwa.ts` |
33
34
  | the closed route vocabulary every renderer names | `route-vocabulary.ts` |
34
35
  | runtime roles + `ROLE` resolution | `roles.ts` |
35
36
  | `Clock` — the only source of "now" | `clock.ts` |
@@ -487,6 +488,17 @@ string, and the cap alone lets one locale evict itself under three spellings. A
487
488
  `Intl` construction, never a wrong answer, which is what makes the bound safe. It lives here rather
488
489
  than in `@ultimat3/time` because `@ultimat3/money` needs it too and tier 1 may not import sideways.
489
490
 
491
+ The **refusal** is bounded for the same reason the cache is. `X_LOCALE_INVALID` answers 400 and
492
+ `@ultimat3/http`'s `toProblem` copies a `cause` into the response `detail` **and** into the log
493
+ line, so a tag echoed verbatim is a stranger-chosen string of unbounded length in a shared log
494
+ index. `localeInvalid` quotes back the first `MAX_LOCALE_EXCERPT` (35) code points — RFC 5646
495
+ §4.4.1's own figure for the longest tag the registry can form — and appends
496
+ `(truncated at 35 characters)` when there were more, so a cut value is never read as a whole one.
497
+ `describeValue` is deliberately **not** used here: "a 5-character string" deletes the only
498
+ actionable content in a sentence whose job is to name the tag. The whole tag rides in
499
+ `meta.locale`, which is the half that gives a redactor a key to address; a value spliced into
500
+ prose has none.
501
+
490
502
  ## One flight layer — wait, classify, share, bound, fence
491
503
 
492
504
  ```ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/core",
3
- "version": "17.0.0",
3
+ "version": "18.0.0",
4
4
  "description": "Ultimate's foundation: errors, context, env, config, clock, ids, logging, telemetry, lifecycle",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -30,11 +30,13 @@
30
30
  "LICENSE"
31
31
  ],
32
32
  "engines": {
33
- "bun": ">=1.3.0"
33
+ "bun": ">=1.4.0"
34
34
  },
35
35
  "scripts": {
36
36
  "typecheck": "tsc --noEmit -p tsconfig.json",
37
37
  "test": "bun test"
38
38
  },
39
- "dependencies": {}
39
+ "dependencies": {
40
+ "@ultimat3/schema": "18.0.0"
41
+ }
40
42
  }
@@ -0,0 +1,118 @@
1
+ // Single responsibility: the `pwa` block of `app.config.ts` — what an app must say before a
2
+ // browser will offer to install it, and the boot-time refusal when it has not.
3
+ //
4
+ // SPLIT OUT OF `config.ts` because that file reached its 500-line ceiling, and this is the seam
5
+ // that costs nothing to cross: `enabled` turns four other requirements on, so the shape, the screen
6
+ // and the remedy are one subject. `config.ts` keeps the one call.
7
+ //
8
+ // WHY THE SCREEN IS HERE AND NOT AT EMIT. `@ultimat3/pwa`'s `generateWebManifest` refuses a blank
9
+ // title too, and that refusal arrives at `x build` — which is the wrong moment: an agent that ran
10
+ // `x dev`, saw a served app and shipped it would meet the missing title in CI. Two checks, two
11
+ // subjects (a config file, and a function argument a library caller supplies directly).
12
+
13
+ import { describeValue } from './error-render';
14
+ // `app.config.ts` CONSUMES the route vocabulary; it does not own it — `config.ts`'s rule, and the
15
+ // reason `OfflineStrategy` is imported rather than restated.
16
+ import type { OfflineStrategy } from './route-vocabulary';
17
+
18
+ /**
19
+ * `installPrompt` was removed 2026-08, same rule: `@ultimat3/pwa`'s `createInstallController` is
20
+ * real and complete, nothing ever threaded the flag into it, and both tracked apps plus every
21
+ * scaffolded app set a switch with no wire. Call the controller from your own affordance instead.
22
+ */
23
+ export interface PwaConfig {
24
+ readonly enabled: boolean;
25
+ readonly offline: OfflineStrategy;
26
+ readonly backgroundSync: boolean;
27
+ readonly push: boolean;
28
+ /**
29
+ * The install title, and the one manifest member nothing can derive. `AppConfig.name` is a slug
30
+ * (`^[a-z][a-z0-9-]{1,63}$`) and an install prompt shows a person a title, so `ledger-demo` is
31
+ * the wrong answer rather than a rough one. Required once `enabled` is true; `''` is what a
32
+ * disabled block resolves to.
33
+ */
34
+ readonly name: string;
35
+ /**
36
+ * `theme_color` and `background_color`, per scheme. Required once `enabled` is true, and
37
+ * `undefined` otherwise — never a colour the framework picked. There is no defensible default:
38
+ * an install splash painted in a colour nobody chose is a wrong-looking app that boots, which is
39
+ * worse than a boot that names the four values it needs.
40
+ */
41
+ readonly colors: PwaColors | undefined;
42
+ }
43
+
44
+ /**
45
+ * The install chrome's two colours for one scheme, as CSS colour strings.
46
+ *
47
+ * ONE OF THE TWO PLACES A RAW COLOUR IS LEGAL, alongside `ThemeConfig.tokens` one section up, and
48
+ * for a stronger reason than that one has: a browser paints the install splash and the address bar
49
+ * from these before a single stylesheet has loaded, so there is no token to resolve them against
50
+ * and no component anywhere in the loop.
51
+ */
52
+ export interface PwaSchemeColors {
53
+ readonly themeColor: string;
54
+ readonly backgroundColor: string;
55
+ }
56
+
57
+ /**
58
+ * Both schemes, because a web manifest carries exactly one `theme_color` and `<head>` carries a
59
+ * media-scoped `<meta name="theme-color">` per scheme. One value would make the dark answer the
60
+ * light one's, on the surface a reader sees before the app has painted anything.
61
+ */
62
+ export interface PwaColors {
63
+ readonly light: PwaSchemeColors;
64
+ readonly dark: PwaSchemeColors;
65
+ }
66
+
67
+ /**
68
+ * The two schemes and the two colours `validate` screens, for `INBOX_RETENTION_KEYS`' reason: a
69
+ * third member added to `PwaColors` or `PwaSchemeColors` without a row here is a value an app can
70
+ * leave blank, and `config.test.ts` asserts both lists against the types so the omission is a red
71
+ * test rather than a manifest with an empty `theme_color`.
72
+ */
73
+ export const PWA_SCHEMES = ['light', 'dark'] as const;
74
+ export const PWA_COLOR_KEYS = ['themeColor', 'backgroundColor'] as const;
75
+
76
+ /**
77
+ * Appended only when the `pwa` block is what failed, and it carries the whole block rather than
78
+ * the missing key: `pwa.enabled` is what turns four other requirements on, so a reader who set one
79
+ * boolean needs to see the complete shape and the opt-out in the same line.
80
+ */
81
+ export const PWA_FIX =
82
+ "in app.config.ts, complete the pwa block: pwa: { enabled: true, offline: 'runtime', name: 'My App', colors: { light: { themeColor: '#1b1f3b', backgroundColor: '#ffffff' }, dark: { themeColor: '#1b1f3b', backgroundColor: '#0b0d1a' } } } — a browser paints the install splash and the address bar from those four values before any stylesheet loads, so there is nothing for the framework to derive them from; or set pwa.enabled: false";
83
+
84
+ /**
85
+ * Every rule `validate` applies to the block, appended to the caller's own issue list. Answers
86
+ * whether IT found anything, so the pwa remedy rides only on a pwa finding: `issues` may already
87
+ * hold a bad locale, and a fix line naming the install block for that is axiom 4 broken.
88
+ *
89
+ * `describeValue` for the reason the retention windows use it — this is where an untyped config
90
+ * object crosses into the framework, so every value here is `unknown` however the interface types it.
91
+ */
92
+ export function pwaIssues(pwa: PwaConfig, issues: string[]): boolean {
93
+ if (!pwa.enabled) return false;
94
+ const before = issues.length;
95
+ const { name, colors } = pwa;
96
+ if (typeof name !== 'string' || name.trim() === '') {
97
+ issues.push(`pwa.name is required when pwa.enabled is true, and is ${describeValue(name)}`);
98
+ }
99
+ // `typeof !== 'object' || null`, never `=== undefined`: an untyped `app.config.ts` writing
100
+ // `pwa.colors: null` reached `null[scheme]` one line down and took the boot out with a native
101
+ // `TypeError`, from the validator whose whole job is producing an instruction instead of one.
102
+ if (colors === null || typeof colors !== 'object') {
103
+ issues.push(
104
+ `pwa.colors is required when pwa.enabled is true, for both light and dark, and is ${describeValue(colors)}`,
105
+ );
106
+ } else {
107
+ for (const scheme of PWA_SCHEMES) {
108
+ for (const key of PWA_COLOR_KEYS) {
109
+ const value: unknown = colors[scheme]?.[key];
110
+ if (typeof value === 'string' && value.trim() !== '') continue;
111
+ issues.push(
112
+ `pwa.colors.${scheme}.${key} must be a CSS colour, not ${describeValue(value)}`,
113
+ );
114
+ }
115
+ }
116
+ }
117
+ return issues.length > before;
118
+ }
package/src/config.ts CHANGED
@@ -2,16 +2,16 @@
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';
5
6
  // Same rule for the same reason: `app.config.ts` CONSUMES the cache tier names, it does not own
6
7
  // them. Declaring them here is what let `cache.tiers` and the ladder `@ultimat3/cache` orders by
7
8
  // drift into two vocabularies with no map between them (issue #293).
8
9
  import { CACHE_TIERS, type CacheTierName } from './cache-vocabulary';
10
+ import type { PwaConfig } from './config-pwa';
11
+ import { PWA_FIX, pwaIssues } from './config-pwa';
12
+ import { describeValue } from './error-render';
9
13
  import { ConfigInvalidError } from './errors';
10
14
  import { ROLES, type Role } from './roles';
11
- // `app.config.ts` CONSUMES the route vocabulary; it does not own it. Declaring `OfflineStrategy`
12
- // here is what made it copyable — `render`, `manifest` and `pwa` each wrote their own rather than
13
- // import a name that reads like a config key.
14
- import type { OfflineStrategy } from './route-vocabulary';
15
15
  import { isIanaZoneName } from './time-zone-name';
16
16
 
17
17
  export type ThemeMode = 'light' | 'dark' | 'system';
@@ -41,17 +41,37 @@ export interface AuthConfig {
41
41
  }
42
42
 
43
43
  /**
44
- * `installPrompt` was removed 2026-08, same rule: `@ultimat3/pwa`'s `createInstallController` is
45
- * real and complete, nothing ever threaded the flag into it, and both tracked apps plus every
46
- * scaffolded app set a switch with no wire. Call the controller from your own affordance instead.
44
+ * Retention for `x_notify_inbox`, and the one framework table whose window the framework may not
45
+ * pick. Every other one holds bookkeeping whose job ENDS — an idempotency key, a rate-limit
46
+ * bucket, an auth challenge, a delivery claim — and a sweep is unambiguously right. An inbox row
47
+ * is a message a person has not read yet, so when it disappears is a product decision, which is
48
+ * axiom 8: Ultimate ships mechanism, your app ships convention.
49
+ *
50
+ * BOTH DEFAULT TO `undefined`, meaning never swept, and that is the only safe default here: the
51
+ * failure mode of keeping rows is a table that grows, and the failure mode of guessing a number is
52
+ * a notification the recipient never got to read.
53
+ *
54
+ * TWO WINDOWS RATHER THAN ONE, because the axiom-8 objection is only about UNREAD messages. An app
55
+ * that wants read notices gone in a month and unread ones kept forever must be able to say exactly
56
+ * that, and `SQL_NOTIFY_INBOX_MARK_READ` already stamps the time that makes it expressible.
57
+ *
58
+ * Milliseconds and not a `DurationInput`: that type lives in `@ultimat3/jobs` (tier 3) and this
59
+ * package is tier 0, which is the same reason `cache.defaultTtlMs` and `jobs.visibilityTimeoutMs`
60
+ * are spelled this way.
47
61
  */
48
- export interface PwaConfig {
49
- readonly enabled: boolean;
50
- readonly offline: OfflineStrategy;
51
- readonly backgroundSync: boolean;
52
- readonly push: boolean;
62
+ export interface NotifyConfig {
63
+ /** Age of a row's `read_at`, not its `created_at` — a row ages from when it was READ. */
64
+ readonly inboxReadRetentionMs: number | undefined;
65
+ /** Age of an unread row's `created_at`. Setting this deletes messages nobody has read. */
66
+ readonly inboxUnreadRetentionMs: number | undefined;
53
67
  }
54
68
 
69
+ /**
70
+ * Every window `validate` screens, derived from nothing — a third one added to `NotifyConfig`
71
+ * without a row here is a window an app can set to `-1`, and `config.test.ts` asserts the two
72
+ * lists agree so the omission is a red test rather than a silent hole.
73
+ */
74
+ export const INBOX_RETENTION_KEYS = ['inboxReadRetentionMs', 'inboxUnreadRetentionMs'] as const;
55
75
  /**
56
76
  * Deliberately thin. `urlEnv`, `poolSize` and `schema` were removed 2026-08 because **nothing
57
77
  * read them** — the only reader of any `config.database.*` field in the repo was this file's own
@@ -166,6 +186,7 @@ export interface AppConfig {
166
186
  readonly cache: CacheConfig;
167
187
  readonly jobs: JobsConfig;
168
188
  readonly realtime: RealtimeConfig;
189
+ readonly notify: NotifyConfig;
169
190
  readonly ai: AiConfig;
170
191
  }
171
192
 
@@ -190,6 +211,7 @@ export interface AppConfigInput {
190
211
  readonly cache?: Input<CacheConfig> | undefined;
191
212
  readonly jobs?: Input<JobsConfig> | undefined;
192
213
  readonly realtime?: Input<RealtimeConfig> | undefined;
214
+ readonly notify?: Input<NotifyConfig> | undefined;
193
215
  readonly ai?: AiConfigInput | undefined;
194
216
  }
195
217
 
@@ -212,18 +234,13 @@ function section<T extends object>(base: T, patch: Input<T> | undefined): T {
212
234
  const NAME_RE = /^[a-z][a-z0-9-]{1,63}$/;
213
235
 
214
236
  /**
215
- * A deliberate duplicate of `CURRENCY_CODE_PATTERN` in `packages/schema/src/money-value.ts`, which
216
- * is the framework's ONE declaration of what an ISO 4217 code looks like and the source
217
- * `isCurrencyCode`, the published OpenAPI `pattern` and `@ultimat3/entity`'s Postgres CHECK all
218
- * derive from. This file cannot import it: `core` and `schema` are both tier 0 and `core → schema`
219
- * is not in `SIDEWAYS_ALLOW` (`scripts/lib/tiers.ts`), the same wall that makes `describeValue` a
220
- * character-for-character copy in `error-render.ts`.
221
- *
222
- * So keep the two identical, and keep the pattern inside the syntax ECMAScript, JSON Schema and
223
- * POSIX ERE spell identically — a `defaultCurrency` this accepts and `t.money` refuses is an app
224
- * whose configured currency cannot be written to a row.
237
+ * Built from `@ultimat3/schema`'s `CURRENCY_CODE_PATTERN`, the framework's ONE declaration of what
238
+ * an ISO 4217 code looks like — the same source `isCurrencyCode`, the published OpenAPI `pattern`
239
+ * and `@ultimat3/entity`'s Postgres CHECK all derive from. It was a character-for-character copy
240
+ * here until the `core -> schema` edge was declared (`scripts/lib/tiers.ts`), held equal only by a
241
+ * pin test in `@ultimat3/cli`.
225
242
  */
226
- const CURRENCY_RE = /^[A-Z]{3}$/;
243
+ const CURRENCY_RE = new RegExp(CURRENCY_CODE_PATTERN);
227
244
 
228
245
  function isLocale(value: string): boolean {
229
246
  try {
@@ -241,7 +258,14 @@ function defaults(name: string): Omit<AppConfig, 'name'> {
241
258
  defaultCurrency: 'USD',
242
259
  theme: { defaultMode: 'system', tokens: {} },
243
260
  auth: { signInPath: null },
244
- pwa: { enabled: false, offline: 'network-only', backgroundSync: false, push: false },
261
+ pwa: {
262
+ enabled: false,
263
+ offline: 'network-only',
264
+ backgroundSync: false,
265
+ push: false,
266
+ name: '',
267
+ colors: undefined,
268
+ },
245
269
  roles: [...ROLES],
246
270
  database: { driver: 'postgres', ssl: false },
247
271
  cache: { defaultTtlMs: 60_000, tiers: ['request-memo', 'lru'] },
@@ -253,6 +277,7 @@ function defaults(name: string): Omit<AppConfig, 'name'> {
253
277
  visibilityTimeoutMs: 30_000,
254
278
  },
255
279
  realtime: { enabled: false, transport: 'memory', urlEnv: undefined },
280
+ notify: { inboxReadRetentionMs: undefined, inboxUnreadRetentionMs: undefined },
256
281
  ai: { mcp: { expose: true, path: '/mcp' } },
257
282
  };
258
283
  }
@@ -284,6 +309,8 @@ function validate(config: AppConfig): void {
284
309
  // Same shape, and it exists for the upgrade: an 8.0.0 app carrying `['memo', 'shared']` in an
285
310
  // untyped config file reaches here rather than the compiler, and needs the new spelling.
286
311
  const tierFix: string[] = [];
312
+ // Same shape again: carried only when an installable app is missing what an install needs.
313
+ const pwaFix: string[] = [];
287
314
 
288
315
  if (!NAME_RE.test(config.name)) {
289
316
  issues.push(`name "${config.name}" must match ${String(NAME_RE)}`);
@@ -313,6 +340,27 @@ function validate(config: AppConfig): void {
313
340
  if (config.realtime.transport !== 'memory' && config.realtime.urlEnv === undefined) {
314
341
  issues.push(`realtime.transport "${config.realtime.transport}" requires realtime.urlEnv`);
315
342
  }
343
+ // BOTH RETENTION WINDOWS OR NEITHER — `undefined` is a real value here (never swept) and the
344
+ // only other legal one is a positive, finite count of milliseconds. Zero is refused rather than
345
+ // read as "immediately": a sweep at age 0 deletes every row the instant it is written, which is
346
+ // an inbox that silently receives nothing, and nobody types it on purpose. `describeValue`
347
+ // rather than `${…}`: this validator is the boundary a JS app's untyped config crosses, so the
348
+ // value here is `unknown` in practice however the interface types it.
349
+ for (const key of INBOX_RETENTION_KEYS) {
350
+ const ms: unknown = config.notify[key];
351
+ if (ms === undefined) continue;
352
+ if (typeof ms !== 'number' || !Number.isFinite(ms) || ms <= 0) {
353
+ issues.push(
354
+ `notify.${key} must be a positive number of milliseconds, not ${describeValue(ms)}`,
355
+ );
356
+ }
357
+ }
358
+
359
+ // What an install needs, asked at BOOT and not at emit — `config-pwa.ts` owns the rules and the
360
+ // remedy, because `pwa.enabled` turning four other requirements on is a question about that block
361
+ // and nothing else here.
362
+ if (pwaIssues(config.pwa, issues)) pwaFix.push(PWA_FIX);
363
+
316
364
  // A rung the ladder cannot build is the defect this key had: `sortTiers` places a name by its
317
365
  // index in `CACHE_TIERS`, and a name missing from it sorts to `-1` — AHEAD of the request memo.
318
366
  // So an unknown tier is refused at boot rather than silently ignored or silently placed first.
@@ -327,7 +375,7 @@ function validate(config: AppConfig): void {
327
375
  cause: issues.join('; '),
328
376
  // The generic instruction goes LAST so the fix line still ends in a command that can be
329
377
  // pasted — a trailing `.` after `x verify` is a command nobody can run.
330
- fix: [...zoneFix, ...tierFix, BASE_FIX].join('. '),
378
+ fix: [...zoneFix, ...tierFix, ...pwaFix, BASE_FIX].join('. '),
331
379
  meta: { issues },
332
380
  });
333
381
  }
@@ -363,6 +411,7 @@ export function defineConfig(
363
411
  cache: section(base.cache, merged.cache),
364
412
  jobs: section(base.jobs, merged.jobs),
365
413
  realtime: section(base.realtime, merged.realtime),
414
+ notify: section(base.notify, merged.notify),
366
415
  ai: { mcp: section(base.ai.mcp, merged.ai?.mcp) },
367
416
  };
368
417
 
@@ -252,57 +252,14 @@ export function renderMetaRecord(
252
252
  * There is no dev-only escape hatch on purpose — a flag is one misconfigured environment away
253
253
  * from being the same breach.
254
254
  *
255
- * A deliberate duplicate of `describeValue` in `packages/schema/src/describe-value.ts`, for the
256
- * reason `SCHEMA_ERROR_CODE_TITLES` is one: `@ultimat3/schema` is tier 0 alongside this package,
257
- * so neither may import the other. Keep the two answering IDENTICALLY — that is what
258
- * `packages/cli/src/describe-value-pin.test.ts` holds — and changing one alone is the bug.
259
- */
260
- export function describeValue(value: unknown): string {
261
- if (value === undefined) return 'undefined';
262
- if (value === null) return 'null';
263
- switch (typeof value) {
264
- case 'string':
265
- return countOf(charCount(value), 'string', 'character');
266
- case 'number':
267
- return describeNumber(value);
268
- case 'boolean':
269
- return 'a boolean';
270
- case 'bigint':
271
- return 'a bigint';
272
- case 'symbol':
273
- return 'a symbol';
274
- case 'function':
275
- return 'a function';
276
- default:
277
- break;
278
- }
279
- if (Array.isArray(value)) return countOf(value.length, 'array', 'item');
280
- // `getTime()` rather than a value: an invalid Date is the one Date fact a caller can act on.
281
- if (value instanceof Date) return Number.isNaN(value.getTime()) ? 'an invalid Date' : 'a Date';
282
- return 'an object';
283
- }
284
-
285
- function describeNumber(value: number): string {
286
- if (Number.isNaN(value)) return 'NaN';
287
- if (value === Number.POSITIVE_INFINITY) return 'Infinity';
288
- if (value === Number.NEGATIVE_INFINITY) return '-Infinity';
289
- return 'a number';
290
- }
291
-
292
- function countOf(size: number, noun: string, unit: string): string {
293
- if (size === 0) return `an empty ${noun}`;
294
- const article = noun === 'array' ? 'an' : 'a';
295
- return `${article} ${noun} of ${size} ${unit}${size === 1 ? '' : 's'}`;
296
- }
297
-
298
- /**
299
- * The twin of `@ultimat3/schema`'s `char-count.ts`, duplicated for the same reason `describeValue`
300
- * is: both packages are tier 0 and neither may import the other. Code points, because the rules
301
- * that reject a string count in them and the message must quote the same unit — `'👍'.length` is 2.
302
- * Only a surrogate makes the two counts differ, so every ASCII value keeps the O(1) read.
255
+ * DECLARED IN `@ultimat3/schema` and re-exported here, `As of 2026-08-27`. It was a
256
+ * character-for-character copy of `packages/schema/src/describe-value.ts` — the two tier-0
257
+ * packages could not import each other — held equal by a 63-line behavioural pin in
258
+ * `@ultimat3/cli`, a TIER-5 package, that no rule required to exist. Whose function it is has not
259
+ * changed: schema's `expected()` is the other reader, and a widening that leaks a value is now one
260
+ * edit in one file rather than two files that can drift apart in silence.
261
+ *
262
+ * `charCount` went with it — the same duplication, one call deep — so the unit a message quotes
263
+ * and the unit a length rule counts in are the same code, not two files that agree today.
303
264
  */
304
- const HAS_SURROGATE = /[\uD800-\uDBFF]/;
305
-
306
- function charCount(value: string): number {
307
- return HAS_SURROGATE.test(value) ? [...value].length : value.length;
308
- }
265
+ export { describeValue } from '@ultimat3/schema';
package/src/index.ts CHANGED
@@ -65,13 +65,15 @@ export type {
65
65
  DatabaseConfig,
66
66
  JobsConfig,
67
67
  McpConfig,
68
- PwaConfig,
68
+ NotifyConfig,
69
69
  RealtimeConfig,
70
70
  RealtimeTransport,
71
71
  ThemeConfig,
72
72
  ThemeMode,
73
73
  } from './config';
74
- export { defineConfig } from './config';
74
+ export { defineConfig, INBOX_RETENTION_KEYS } from './config';
75
+ export type { PwaColors, PwaConfig, PwaSchemeColors } from './config-pwa';
76
+ export { PWA_COLOR_KEYS, PWA_SCHEMES } from './config-pwa';
75
77
  export type { Ctx, CtxFacts, CtxInit, CtxPatch, CtxServices, ServiceBag } from './context';
76
78
  export {
77
79
  createContext,
@@ -461,6 +463,7 @@ export {
461
463
  canonicalLocale,
462
464
  localeInvalid,
463
465
  MAX_CACHED_FORMATTERS,
466
+ MAX_LOCALE_EXCERPT,
464
467
  } from './intl-cache';
465
468
  export { isJsonObject } from './json-object';
466
469
  export type {
package/src/intl-cache.ts CHANGED
@@ -27,6 +27,16 @@ export function cachedFormatter<T>(cache: Map<string, T>, key: string, build: ()
27
27
  return formatter;
28
28
  }
29
29
 
30
+ /**
31
+ * The cap on the tag an `X_LOCALE_INVALID` cause quotes back, in code points.
32
+ *
33
+ * 35 is RFC 5646 §4.4.1's own number — Figure 7 derives it as the longest tag the registry can
34
+ * form (language 8 + script 5 + region 4 + two variants 9+9), and the same section says a protocol
35
+ * with a fixed buffer "MUST allow for language tags of at least 35 characters". So every tag a
36
+ * caller could legitimately have meant fits, and nothing longer is a tag being debugged.
37
+ */
38
+ export const MAX_LOCALE_EXCERPT = 35;
39
+
30
40
  /**
31
41
  * The canonical BCP 47 spelling, or `undefined` when the tag is not structurally valid at all
32
42
  * (`en_US`, `''`, `not a locale`). Well-formed but unknown to ICU (`zz`) is a locale — `Intl`
@@ -53,11 +63,47 @@ export function canonicalLocale(locale: string): string | undefined {
53
63
  export function localeInvalid(locale: string): UltimateError {
54
64
  return new UltimateError({
55
65
  code: 'X_LOCALE_INVALID',
56
- cause: `"${locale}" is not a well-formed BCP 47 language tag`,
66
+ cause: `${localeExcerpt(locale)} is not a well-formed BCP 47 language tag`,
57
67
  fix: "pass a tag like 'en', 'en-GB' or 'de-DE' — screen a header-supplied value with Intl.DateTimeFormat.supportedLocalesOf([tag]) before it reaches a formatter",
68
+ // The raw tag, under a NAME. A `cause` is copied into the 400 body and into the log line by
69
+ // `@ultimat3/http`'s `toProblem`, and a logger redacts by key — so a caller's value spliced
70
+ // into prose has no key left to redact, which is the whole argument `describeValue` rests on.
71
+ // `meta` is the key, and it is machine-read, so it carries the value WHOLE: an excerpt here
72
+ // would be a value a redactor or a bug report reads as complete when it is not.
73
+ meta: { locale },
58
74
  });
59
75
  }
60
76
 
77
+ /**
78
+ * The tag as a reader can act on it: the first `MAX_LOCALE_EXCERPT` code points, quoted, and SAID
79
+ * to be cut when there were more.
80
+ *
81
+ * `describeValue` is the usual answer for a caller-supplied value in a `cause` and it is the wrong
82
+ * one here — it renders `en_US` as "a 5-character string", deleting the only actionable content in
83
+ * a sentence whose entire job is to say WHICH tag was refused. Bounding it keeps the diagnostic and
84
+ * removes the part a stranger chooses: without a cap the whole of an `Accept-Language` value —
85
+ * megabytes, at the caller's option — became the error's `message`, its 400 body and its log line.
86
+ *
87
+ * Code points, never `slice`: cutting between a surrogate pair leaves a lone surrogate, which is
88
+ * not text and survives no encoder between here and the log index. The string iterator is lazy, so
89
+ * a megabyte tag costs 35 steps rather than a megabyte-long array.
90
+ *
91
+ * No escaping here: `UltimateError`'s constructor runs `singleLine` over every line-bearing field
92
+ * exactly once, which is what keeps a newline in a tag from writing a second line an operator reads
93
+ * as a genuine framework message. A second pass at this call site would be a second place that has
94
+ * to be right.
95
+ */
96
+ function localeExcerpt(locale: string): string {
97
+ let head = '';
98
+ let taken = 0;
99
+ for (const char of locale) {
100
+ if (taken === MAX_LOCALE_EXCERPT) return `"${head}" (truncated at ${taken} characters)`;
101
+ head += char;
102
+ taken += 1;
103
+ }
104
+ return `"${head}"`;
105
+ }
106
+
61
107
  /**
62
108
  * The canonical spelling of a well-formed tag, or `X_LOCALE_INVALID`. The ONE screen a
63
109
  * caller-supplied BCP 47 tag passes before it reaches an `Intl` constructor.
@@ -1,32 +1,33 @@
1
1
  // Single responsibility: register `@ultimat3/schema`'s error codes so their titles render for any
2
2
  // process that imports `@ultimat3/core` — not just the CLI. `@ultimat3/schema` is tier 0 alongside
3
- // this package, so it cannot call `registerErrorCodes()` itself (that would mean importing core,
4
- // a same-tier import) and this package cannot import schema to read its declarations back (same
5
- // reason, the other direction). The codes below are a deliberate, tested duplicate of
6
- // `SCHEMA_ERROR_CODES` in `packages/schema/src/errors.ts` — `schema-error-codes-pin.test.ts`, in a
7
- // package that may legally import both (`@ultimat3/cli`), asserts them equal so a title edited in
8
- // one place and not the other fails the build instead of quietly disagreeing at runtime.
3
+ // this package and cannot call `registerErrorCodes()` itself: that would be `schema -> core`, an
4
+ // import this package's own dependency-free promise forbids in that direction and always will.
5
+ // The other direction is DECLARED (`core -> schema`, `scripts/lib/tiers.ts`), so the titles are
6
+ // READ from schema rather than restated here.
7
+ //
8
+ // They were a hand-kept duplicate until 2026-08-27, held equal by `schema-error-codes-pin.test.ts`
9
+ // in `@ultimat3/cli` — a tier-5 package pinning a tier-0 invariant, which nothing required to
10
+ // exist. It is deleted with this change.
9
11
 
12
+ import { SCHEMA_ERROR_CODES } from '@ultimat3/schema';
10
13
  import { registerErrorCodes } from './error-codes';
11
14
  import { registerErrorRetry } from './error-retry';
12
15
 
13
- /** Mirrors `SCHEMA_ERROR_CODES` in `packages/schema/src/errors.ts`. Keep the titles identical. */
14
- export const SCHEMA_ERROR_CODE_TITLES: Readonly<Record<string, string>> = Object.freeze({
15
- X_VALIDATION_FAILED: 'value did not match its schema',
16
- X_SCHEMA_UNSUPPORTED: 'the active schema provider cannot do this',
17
- X_SCHEMA_DISCRIMINANT_INVALID: 'a discriminated union member can never be dispatched to',
18
- X_SCHEMA_DEFAULT_UNSHAREABLE: 'a schema default cannot be copied per parse',
19
- });
16
+ /**
17
+ * Schema's own declarations, projected to titles. Kept as an export because it is public API
18
+ * shipped since 1.0; it is now DERIVED and can no longer disagree with its source.
19
+ */
20
+ export const SCHEMA_ERROR_CODE_TITLES: Readonly<Record<string, string>> = Object.freeze(
21
+ Object.fromEntries(
22
+ Object.entries(SCHEMA_ERROR_CODES).map(([code, declared]) => [code, declared.title]),
23
+ ),
24
+ );
20
25
 
21
26
  // Registered here rather than in `error-codes.ts`'s `CORE_CODE_TITLES` because core does not own
22
27
  // these codes — `@ultimat3/schema` does — and `registerErrorCodes` is the one mechanism that
23
28
  // raises `X_ERROR_CODE_DUPLICATE` if a package that DOES own one of them ever tries to register it
24
- // too, which pins ownership even though the titles live in two files.
25
- registerErrorCodes(
26
- Object.fromEntries(
27
- Object.entries(SCHEMA_ERROR_CODE_TITLES).map(([code, title]) => [code, { title }]),
28
- ),
29
- );
29
+ // too, which pins ownership even though the registration happens here.
30
+ registerErrorCodes(SCHEMA_ERROR_CODES);
30
31
 
31
32
  // And how each is RETRIED, here for the same tier reason the titles are here.
32
33
  //
@@ -41,9 +42,10 @@ registerErrorCodes(
41
42
  // `packages/scraping/src/cdp-target.ts` names the gap and says it cannot be closed from there.
42
43
  //
43
44
  // A value that does not match its schema does not match it on attempt five either.
44
- registerErrorRetry({
45
- X_VALIDATION_FAILED: 'terminal',
46
- X_SCHEMA_UNSUPPORTED: 'terminal',
47
- X_SCHEMA_DISCRIMINANT_INVALID: 'terminal',
48
- X_SCHEMA_DEFAULT_UNSHAREABLE: 'terminal',
49
- });
45
+ //
46
+ // DERIVED from the same set, so a code schema adds cannot arrive unclassified: the list was typed
47
+ // out here and a fifth code would have been silently missing from it, which is precisely the
48
+ // "unregistered reads as unclassified" failure the paragraph above describes.
49
+ registerErrorRetry(
50
+ Object.fromEntries(Object.keys(SCHEMA_ERROR_CODES).map((code) => [code, 'terminal' as const])),
51
+ );
@@ -1,43 +1,14 @@
1
- // Single responsibility: is a string an IANA zone NAME? Tier 0's statement of the one rule
2
- // `@ultimat3/time` enforces everywhere above it — a zone is `Area/Location`, and `UTC` is the one
3
- // exception. It is stated twice because `core` is tier 0 and may not import `@ultimat3/time`;
4
- // `packages/time/src/zone-canonical.ts` is where the rule and its reasoning are written down.
1
+ // Single responsibility: re-export `@ultimat3/schema`'s IANA zone-name predicate, so `app.config.ts`
2
+ // and `t.timezone` judge a zone with ONE function.
3
+ //
4
+ // This file held a character-for-character copy of it until 2026-08-27 — `core` and `schema` are
5
+ // both tier 0 and neither could import the other — kept equal by a 123-line pin test in
6
+ // `@ultimat3/cli`, a tier-5 package pinning a tier-0 invariant that no rule required to exist.
7
+ // `core -> schema` is now a declared edge (`scripts/lib/tiers.ts`), so the copy is gone.
8
+ //
9
+ // The rule itself, and why a bare `new Intl.DateTimeFormat(…)` probe is not it — ICU 78 resolves
10
+ // `CET`, `EST`, `Japan`, `GMT` and `Zulu`, so `defaultTimeZone: 'CET'` passed validation at boot
11
+ // and threw `X_TIMEZONE_INVALID` on the first `format` call (issue #257) — is written down in
12
+ // `packages/schema/src/time-zone-name.ts` and in `packages/time/src/zone-canonical.ts`.
5
13
 
6
- /**
7
- * A LEADING sign is a fixed offset, which carries no DST rules. `Etc/GMT+2` keeps its `+`.
8
- *
9
- * Unobservable on ICU 78 — `+01:00` resolves to itself, so the slash rule below already refuses it,
10
- * and deleting this line changes no answer this package can currently produce. It stays because it
11
- * guards the runtime that folds an offset into `Etc/GMT-1`, which WOULD carry a slash, and because
12
- * `packages/time/src/zone-canonical.ts` carries the same line: two statements of one rule may not
13
- * differ, least of all in the half that is hard to test.
14
- */
15
- const NUMERIC_OFFSET = /^[+-]/;
16
-
17
- /**
18
- * Structural, and never delegated to `Intl` — the reasoning, and why a denylist is not the
19
- * alternative, is `packages/time/src/zone-canonical.ts`'s and is not re-derived here. What this
20
- * file enforces is that same rule for `app.config.ts`: an identifier is `Area/Location`, `UTC` is
21
- * the one legal exception, and a leading sign is an offset rather than a name.
22
- *
23
- * It exists because a bare `new Intl.DateTimeFormat(…)` probe was the second answer to that
24
- * question, and the two stopped agreeing: ICU 78 (Bun 1.4) resolves `CET`, `EST`, `Japan`, `GMT`,
25
- * `Zulu` and the whole `backward`/abbreviation family, so `defaultTimeZone: 'CET'` passed validation
26
- * at boot and threw `X_TIMEZONE_INVALID` on the first `format` call — a config file accepting a
27
- * value nothing downstream can use (issue #257).
28
- *
29
- * The judgement is made on the RESOLVED name, not the input, so a casing (`utc`) and a runtime that
30
- * folds an alias into its target (`Etc/UTC` → `UTC`) both answer correctly. No cache: this is read
31
- * once per process at config validation, never off a request header — a zone that arrives from a
32
- * caller goes through `@ultimat3/time`'s `canonicalTimeZone`, which is bounded and canonicalizing.
33
- */
34
- export function isIanaZoneName(value: string): boolean {
35
- if (value === '' || NUMERIC_OFFSET.test(value)) return false;
36
- try {
37
- const resolved = new Intl.DateTimeFormat('en-US', { timeZone: value }).resolvedOptions()
38
- .timeZone;
39
- return resolved === 'UTC' || resolved.includes('/');
40
- } catch {
41
- return false;
42
- }
43
- }
14
+ export { isIanaZoneName } from '@ultimat3/schema';