@ultimat3/core 16.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 | `intl-cache.ts` (`cachedFormatter`, `canonicalLocale`, `MAX_CACHED_FORMATTERS`) | a locale and a zone arrive from a request header, so the key must be canonical AND the cache bounded — never a second copy of either half |
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
@@ -144,6 +155,15 @@ and live in one file for that reason: a canonical key bounds nothing (an unknown
144
155
  value survives canonicalization as a distinct string) and the cap alone lets one locale evict
145
156
  itself under three spellings. Never build an `Intl` formatter on a caller string without both.
146
157
 
158
+ `assertLocale` and `X_LOCALE_INVALID` moved here from `@ultimat3/time` in 16.x, for the third
159
+ time the same argument was made: `@ultimat3/money` handed a malformed `Accept-Language` tag
160
+ straight to `Intl.NumberFormat` at four entry points and let a bare `RangeError` out, and copying
161
+ time's screen down would have been a second answer to one question. Validating and keying are ONE
162
+ step — `getCanonicalLocales` runs exactly the check `supportedLocalesOf` throws on and hands back
163
+ the spelling the cache keys on — which is why the screen lives in this file and not beside it. A
164
+ code has one declaration: `X_LOCALE_INVALID` left `TIME_ERROR_CODES` in the same change, or
165
+ `registerErrorCodes` would raise `X_ERROR_CODE_DUPLICATE` the moment both packages loaded.
166
+
147
167
  `secrets-errors.ts` registers its seven codes through `registerErrorCodes()` rather than joining
148
168
  `CORE_CODE_TITLES` — the codes and the module that throws them ship together, and `registerErrorCodes`
149
169
  is the one mechanism that raises `X_ERROR_CODE_DUPLICATE` if anything else claims one. Consequence
@@ -153,12 +173,14 @@ the master key) purely so *wrong key* and *edited file* are two codes and not on
153
173
  cannot tell them apart.
154
174
 
155
175
  `schema-error-codes.ts` is the same shape a second time, for codes this package does not even own.
156
- `@ultimat3/schema` is tier 0 like `core` and so can neither call `registerErrorCodes()` itself nor
157
- import core to reach it — the four codes' titles are a deliberate, tested duplicate of
158
- `SCHEMA_ERROR_CODES` in `packages/schema/src/errors.ts`, registered unconditionally at import time
159
- so any process that imports core (not just `@ultimat3/cli`, which used to be the only registrant)
160
- renders schema's real titles. Neither tier-0 package can check the duplicate against its source, so
161
- 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.
162
184
 
163
185
  `timing-safe-equal.ts` holds the one constant-time string comparison `@ultimat3/auth` and
164
186
  `@ultimat3/storage` both need — core is the lowest tier both can reach, so the shared code lives
@@ -218,6 +240,28 @@ the layer imports anything but this package, nothing runs at import time, and ev
218
240
  non-determinism — the roll, the sleep, the clock, the timer — is injected, because a schedule
219
241
  provable only by waiting is a schedule no test pins.
220
242
 
243
+ **`backoffDelay` REFUSES a non-finite bound, `As of 2026-08-26`, and that is a behaviour change.**
244
+ It used to answer `0`, on the argument that 0 is "wrong loudly". It is not: measured,
245
+ `retry({ attempts: 5, max: NaN })` slept `[0, 0, 0, 0]` and `factor: NaN` slept `[1000, 0, 0, 0]` —
246
+ a retry loop with no wait at all, which is the failure backoff exists to prevent, on the tree's ONE
247
+ curve (`bun run flight-copies`), reaching every retry in the framework. `attempt`, `base`, `max`
248
+ and `factor` each go through `finiteOption` before the clamps, because `Math.max`, `Math.min` and
249
+ `Math.trunc` propagate rather than validate and `Math.min(raw, Infinity)` is `Infinity`. The
250
+ `return 0` below them stays and is still reachable, by exactly one route: `factor ** (step - 1)`
251
+ overflows around attempt 1030 and `0 * Infinity` is `NaN` — a zero base is a caller asking for no
252
+ wait, which is the answer it gets.
253
+
254
+ **`finiteOption` / `finiteCount` (`finite-option.ts`) are the framework's ONE screen for a numeric
255
+ option, `As of 2026-08-26`.** They landed here because `@ultimat3/jobs`, `@ultimat3/realtime` and
256
+ `@ultimat3/query` each held a byte-identical copy; all three were deleted and now import these.
257
+ `finiteCount(subject, option, value, min)` takes `min: 0 | 1` as a DEFAULT PARAMETER rather than
258
+ shipping a third `finitePositive`, because only the caller knows what zero means —
259
+ `requestTimeoutMs: 0` is "no deadline", a pool `max: 0` is a pool nothing can run on.
260
+ `bun run finite-bounds` is the ratchet, and it recognises a repair by the SHAPE OF THE CALL: a
261
+ package screen that does not carry `Finite` in its name is invisible to it, which is why
262
+ `assertFiniteImageQuality`, `assertFiniteOtlpBound` and their siblings in five other packages are
263
+ spelled that way.
264
+
221
265
  Two rules that are not preferences. `backoffDelay` clamps to `max` **before** jitter: capping after
222
266
  turns `full` into a distribution whose upper half is a single value at `max`, which is the
223
267
  correlation jitter exists to remove. `createFlightGate` **hands** its slot to a waiter rather than
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` |
@@ -316,7 +317,9 @@ nothing and still reports healthy.
316
317
  - **Sampling is honoured, not just propagated.** `startSpan` takes the parent's bit when there is
317
318
  one, else asks the `Sampler`; `span.end()` exports nothing when the bit is 0. The default reads
318
319
  `OTEL_TRACES_SAMPLER` / `OTEL_TRACES_SAMPLER_ARG` at the first span, and
319
- `configureTelemetry({ sampler })` replaces it.
320
+ `configureTelemetry({ sampler })` replaces it. `shouldSample` is handed the TRACE ID of the span
321
+ being created, so `traceidratio` decides once per trace by hashing it — a roll per span left one
322
+ trace half exported, which is orphan children under a root the collector never saw.
320
323
  - **The OTLP exporters ship**, speaking OTLP/HTTP JSON, no dependency:
321
324
  `otlpSpanExporter({ endpoint })` (batched, with `flush()` / `shutdown()`) and
322
325
  `otlpMetricExporter({ endpoint })`. Both default to `OTEL_EXPORTER_OTLP_ENDPOINT`;
@@ -457,13 +460,16 @@ never a silently wrong page.
457
460
  ## One bounded cache for every `Intl` formatter
458
461
 
459
462
  ```ts
460
- import { cachedFormatter, canonicalLocale } from '@ultimat3/core';
463
+ import { assertLocale, cachedFormatter } from '@ultimat3/core';
461
464
 
462
465
  const cache = new Map<string, Intl.NumberFormat>();
463
466
 
464
467
  export function euroFormatter(locale: string): Intl.NumberFormat {
465
- // `EN-us` and `en-latn-us` collapse to one key, so one locale cannot evict itself.
466
- const tag = canonicalLocale(locale) ?? locale;
468
+ // Validates AND canonicalizes: `EN-us` and `en-latn-us` collapse to one key, and a tag `Intl`
469
+ // cannot parse is `X_LOCALE_INVALID` here rather than a bare `RangeError` out of the constructor
470
+ // below. `canonicalLocale(locale) ?? locale` is the same call with the refusal deleted — it is
471
+ // what `@ultimat3/money` did, and an `Accept-Language` value of `en_US` took the request down.
472
+ const tag = assertLocale(locale);
467
473
  return cachedFormatter(
468
474
  cache,
469
475
  `${tag}|EUR`,
@@ -482,6 +488,17 @@ string, and the cap alone lets one locale evict itself under three spellings. A
482
488
  `Intl` construction, never a wrong answer, which is what makes the bound safe. It lives here rather
483
489
  than in `@ultimat3/time` because `@ultimat3/money` needs it too and tier 1 may not import sideways.
484
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
+
485
502
  ## One flight layer — wait, classify, share, bound, fence
486
503
 
487
504
  ```ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/core",
3
- "version": "16.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
  }
package/src/backoff.ts CHANGED
@@ -3,6 +3,8 @@
3
3
  // (equal jitter), `@ultimat3/ai` (full jitter, `Math.random` inline and so untestable),
4
4
  // `@ultimat3/realtime` (full jitter, 0-based attempt) and `@ultimat3/db` (no backoff at all).
5
5
 
6
+ import { finiteOption } from './finite-option';
7
+
6
8
  export type BackoffCurve = 'exponential' | 'linear' | 'fixed';
7
9
 
8
10
  /**
@@ -12,6 +14,9 @@ export type BackoffCurve = 'exponential' | 'linear' | 'fixed';
12
14
  */
13
15
  export type JitterMode = 'full' | 'equal' | 'none';
14
16
 
17
+ /** Named in every refusal below, so a reader knows which call built the schedule. */
18
+ const SUBJECT = 'backoffDelay';
19
+
15
20
  /** Injected everywhere. A delay only provable by observing a range is a delay no test pins. */
16
21
  export type Random = () => number;
17
22
 
@@ -38,19 +43,26 @@ export interface BackoffOptions {
38
43
  * jitter exists to remove.
39
44
  */
40
45
  export function backoffDelay(options: BackoffOptions): number {
41
- const step = Math.max(1, Math.trunc(options.attempt));
42
- const base = Math.max(0, options.base);
43
- const factor = options.factor ?? 2;
46
+ // Every bound is REFUSED before it is clamped, because the clamps are not validators: measured,
47
+ // `retry({ attempts: 5, max: NaN })` slept `[0, 0, 0, 0]` and `factor: NaN` slept
48
+ // `[1000, 0, 0, 0]` — `Math.max`, `Math.min` and `Math.trunc` each propagate it,
49
+ // `Math.min(raw, Infinity)` is `Infinity`, and the `return 0` below then turns the whole
50
+ // schedule into a spin.
51
+ const step = Math.max(1, Math.trunc(finiteOption(SUBJECT, 'attempt', options.attempt)));
52
+ const base = Math.max(0, finiteOption(SUBJECT, 'base', options.base));
53
+ const max = finiteOption(SUBJECT, 'max', options.max);
54
+ const factor = finiteOption(SUBJECT, 'factor', options.factor ?? 2);
44
55
 
45
56
  let raw: number;
46
57
  if (options.curve === 'fixed') raw = base;
47
58
  else if (options.curve === 'linear') raw = base * step;
48
59
  else raw = base * factor ** (step - 1);
49
60
 
50
- const capped = Math.max(0, Math.min(raw, options.max));
51
- // A NaN reaches here from an unvalidated config value, and `setTimeout(NaN)` fires IMMEDIATELY —
52
- // a retry loop with no wait at all, which is the failure mode backoff exists to prevent. 0 is
53
- // wrong too, but it is wrong loudly: the next attempt still happens once, not in a tight spin.
61
+ const capped = Math.max(0, Math.min(raw, max));
62
+ // Still reachable with four finite inputs, and only one way: `factor ** (step - 1)` overflows to
63
+ // `Infinity` around attempt 1030, and `0 * Infinity` is `NaN`. A zero base is a caller asking
64
+ // for no wait, so 0 is the answer it asked for — it is no longer the answer an unvalidated
65
+ // config value gets, which is what this line used to be.
54
66
  if (!Number.isFinite(capped)) return 0;
55
67
 
56
68
  const roll = options.random ?? Math.random;
@@ -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
 
package/src/context.ts CHANGED
@@ -34,6 +34,7 @@ import { type Actor, anonymousActor } from './actor';
34
34
  import { asyncContext } from './async-context';
35
35
  import { type Clock, systemClock } from './clock';
36
36
  import { UltimateError } from './errors';
37
+ import { finiteOption } from './finite-option';
37
38
  import { traceId as newTraceId, uuid } from './ids';
38
39
  import { type Logger, logger as rootLogger, setLoggerContextFields } from './logger';
39
40
  import { type Role, resolveRole } from './roles';
@@ -173,7 +174,7 @@ export function createContext(init: CtxInit = {}): Ctx {
173
174
  now: () => clock.now(),
174
175
  logger: base.child({ requestId, traceId: trace }),
175
176
  signal: init.signal ?? neverAborted,
176
- deadlineAt: init.deadlineAt ?? null,
177
+ deadlineAt: screenDeadline(init.deadlineAt),
177
178
  };
178
179
  // A registered service (`defineService`) closes over the ctx it is built for — actor, clock,
179
180
  // tz — so it has to run HERE, against this exact call's fields, rather than once at boot and
@@ -231,6 +232,35 @@ export function hasContext(): boolean {
231
232
  return tryUseContext() !== undefined;
232
233
  }
233
234
 
235
+ /**
236
+ * The tighter of two instants, where absent means "no bound" — the bounded form
237
+ * `lifecycle.ts`'s drain budget uses, over two optional values instead of one. Returns
238
+ * `undefined` rather than `null` because that is what `CtxInit.deadlineAt` spells.
239
+ */
240
+ function earliest(left: number | undefined, right: number | null | undefined): number | undefined {
241
+ if (left === undefined) return right ?? undefined;
242
+ if (right === undefined || right === null) return left;
243
+ return Math.min(left, right);
244
+ }
245
+
246
+ /**
247
+ * The one screen over `deadlineAt`, at both boundaries that can set it. A deadline is an instant,
248
+ * not a count, so `finiteOption` rather than `finiteCount` — a clock epoch is legitimately large
249
+ * and a test clock's is legitimately small.
250
+ *
251
+ * WHY IT IS REFUSED AND NOT CLAMPED, in this file specifically: `Math.min` PROPAGATES `NaN`, so one
252
+ * non-finite side poisons the child's deadline as well as its own. `remainingBudgetMs` then asks
253
+ * `left >= 1` of a `NaN`, gets `false`, and answers `undefined` — the same answer it gives for "no
254
+ * deadline at all". So the `x-request-timeout-ms` header is silently omitted and the next hop falls
255
+ * back to its OWN configured budget, which `request-budget.ts`'s own header calls "the exact
256
+ * failure this header exists to prevent". A deadline that quietly stops bounding anything is the
257
+ * dangerous direction, and it is the direction `??` cannot see, because `NaN` is not nullish.
258
+ */
259
+ function screenDeadline(value: number | null | undefined): number | null {
260
+ if (value === undefined || value === null) return null;
261
+ return finiteOption('the request context', 'deadlineAt', value);
262
+ }
263
+
234
264
  /**
235
265
  * Derive a narrowed context — impersonation, a locale switch, a per-step abort signal.
236
266
  * `requestId` is deliberately not patchable: one request, one id.
@@ -256,8 +286,10 @@ export function withChildContext<T>(patch: CtxPatch, fn: () => T): T {
256
286
  logger: patch.logger ?? parent.logger,
257
287
  // One request, one budget: a child scope inherits the deadline for the same reason it
258
288
  // inherits `requestId`. A patch may SHORTEN it (a step with its own budget); nothing here
259
- // lengthens it, because the socket the parent is answering does not move.
260
- deadlineAt: patch.deadlineAt ?? parent.deadlineAt ?? undefined,
289
+ // lengthens it, because the socket the parent is answering does not move. `??` alone said
290
+ // that and did not do it — a patched hour replaced a parent's second outright, and
291
+ // `remainingBudgetMs` then put the hour on `x-request-timeout-ms` for the next hop.
292
+ deadlineAt: earliest(screenDeadline(patch.deadlineAt) ?? undefined, parent.deadlineAt),
261
293
  signal: patch.signal ?? parent.signal,
262
294
  services: { ...carried, ...(patch.services ?? {}) },
263
295
  });
@@ -52,6 +52,9 @@ const CORE_CODE_TITLES = {
52
52
  X_IMAGE_UNSUPPORTED: 'the built-in image pipeline cannot read or write this format',
53
53
  X_INTERNAL: 'unexpected internal framework error',
54
54
  X_INVARIANT: 'invariant violated',
55
+ // Owned here rather than by `@ultimat3/time`, which declared it until 16.x: `@ultimat3/money`
56
+ // needs the same screen and tier 1 may not import sideways. One code, one declaration.
57
+ X_LOCALE_INVALID: 'not a well-formed BCP 47 tag',
55
58
  X_METRIC_CARDINALITY:
56
59
  'a metric exceeded its series ceiling and is folding into one overflow series',
57
60
  X_METRIC_NAME_INVALID: