@ultimat3/core 7.0.0 → 9.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
@@ -79,12 +79,17 @@ escape, so a cause could repaint the screen or hide the line above it. `@ultimat
79
79
  deliberate duplicate for the tier-0 reason below, pinned behaviourally by
80
80
  `single-line-pin.test.ts` in `@ultimat3/cli`.
81
81
 
82
- `describeValue` in `error-render.ts` is a character-for-character duplicate of `describeValue` in
82
+ `describeValue` in `error-render.ts` is a deliberate duplicate of `describeValue` in
83
83
  `packages/schema/src/describe-value.ts`, for the same tier-0 reason `SCHEMA_ERROR_CODE_TITLES` is
84
84
  one: schema and core are both tier 0 and `core → schema` is **not** a declared edge in
85
- `scripts/lib/tiers.ts`, so neither may import the other. Keep the two identical; a pin test in
85
+ `scripts/lib/tiers.ts`, so neither may import the other. Keep the two ANSWERING identically that
86
+ is the contract, and the source is no longer character-for-character: schema counts characters
87
+ through `char-count.ts`, which core copies privately. A pin test in
86
88
  `@ultimat3/cli` (which may legally import both) is the mechanical half, the same shape as
87
- `schema-error-codes-pin.test.ts`. The rule it enforces: a `cause` reaches the log index AND the
89
+ `schema-error-codes-pin.test.ts`. **A string's length is CODE POINTS in both, `As of 2026-08-22`**
90
+ — `validators.ts` rejects in that unit and `json-schema.ts` publishes `minLength` in it, so
91
+ `.length` made `t.string.min(3).safeParse('👍a')` say "at least 3 chars, received a string of 3
92
+ characters". The rule it enforces: a `cause` reaches the log index AND the
88
93
  HTTP problem document, redaction is by log FIELD key, and a value baked into a message string has
89
94
  no key left to redact — so `parseId`/`uuidTimestamp` describe a rejected id and never echo it.
90
95
 
@@ -105,6 +110,7 @@ shape against a locally declared sample interface for exactly that reason.
105
110
  | which deploy this is | `environment.ts` (`ULTIMATE_ENV`) | the twin of `ROLE`; never declare a second env var for it |
106
111
  | what this process does | `roles.ts` (`ROLE`) | |
107
112
  | how a route renders, caches offline and hydrates | `route-vocabulary.ts` (`RENDER_MODES`, `OFFLINE_STRATEGIES`, `HYDRATE_STRATEGIES`) | tier 0 because SIX packages name them and imports only go down — `render`, `http`, `seo`, `manifest` and `pwa` each kept a hand-copy until 2026-08, and `'spa'` was deleted from one while five went on admitting it under a green typecheck. Every union is `(typeof ARRAY)[number]`, pinned in `type-pins.ts`; `scripts/render-modes.test.ts` refuses a second declaration anywhere in `packages/*/src`. Re-export it, never restate it |
113
+ | which rungs a cache ladder has | `cache-vocabulary.ts` (`CACHE_TIERS`) | tier 0 for the same reason, one tier lower down the stack: `app.config.ts` picks tiers by name and `@ultimat3/cache` builds them by name, and until 2026-08-22 those were two different vocabularies — config accepted `memo \| lru \| shared \| isr \| cdn`, the ladder ordered `request-memo \| lru \| redis \| cdn`, nothing mapped one onto the other, and `cache: { tiers: ['isr'] }` typechecked and selected nothing (issue #293). `TIER_ORDER` IS this array, so read order and the config vocabulary cannot disagree. `isr` is a `RenderMode`, never a tier. Pinned in `type-pins.ts` and by `scripts/render-modes.ts` |
108
114
  | which build of the APP this is | `app-version.ts` (`APP_VERSION`) | one reader, `dev` by default: `db` writes it into `x_migrations` and `jobs` into `x_backfills`, and `jobs` cannot reach `db` for the answer |
109
115
  | the values | `env.ts` | `checkEnv().values` holds REAL secrets — anything that prints goes through `maskedEnvValues()` |
110
116
  | `.env.example` | `env-example.ts` | a projection of the schema, never hand-maintained |
@@ -175,6 +181,17 @@ NOT — `@ultimat3/query`, whose `OrderKey` is a name and a direction — must n
175
181
  orders a `text` column of digits lexically and a comparator guessing would trade one disagreement
176
182
  with the SQL it printed for another.
177
183
 
184
+ `format-bytes.ts` is the same rule at its smallest, `As of 2026-08-22`: one `formatBytes(bytes)`,
185
+ 1024-base, `b|kb|mb|gb`, for the byte count an error message carries. `@ultimat3/render` (t4) and
186
+ `@ultimat3/pwa` (t4) each had one and they had diverged — render's stopped at `kb`, so a 5 MiB route
187
+ read `5120kb` in `X_BUDGET_EXCEEDED` and `5mb` in the precache warning about the same bytes, and
188
+ `@ultimat3/cli`'s budget error imported render's. Deliberately NOT
189
+ `@ultimat3/ui`'s `formatBytes(bytes, locale)`, which is a different function and stays: that one is
190
+ `Intl`-formatted and DECIMAL (kB = 1000 B, which is what `Intl`'s unit means), for a human reading a
191
+ file picker, where this one must line up with a bundler's own KiB figures and must not move with the
192
+ reader's locale. **Not mechanised** — no gate refuses a third copy, unlike `render-modes.ts` for the
193
+ route vocabulary; a `formatBytes` reappearing in `packages/*/src` is caught by review only.
194
+
178
195
  `mcp-exposure.ts` is the same shape for a declaration rather than an algorithm: `isMcpExposed` is
179
196
  the ONE answer to "did this primitive opt into being an MCP tool?", asked by `action`, `query`
180
197
  (t3), `mcp`, `ai`, `manifest` (t4) — five packages that cannot import each other, so core is the
@@ -207,6 +224,16 @@ which is what stops an exporter from turning 40k rps into 40k rps of spans. `con
207
224
  takes a `Sampler`; the default reads `OTEL_TRACES_SAMPLER*` **at the first span, never at module
208
225
  scope** (same call-time rule as `cursor.ts`'s secret). `resetTelemetry()` drops both.
209
226
 
227
+ **An empty `spanId` means "no inbound decision", and every reader must honour it, `As of
228
+ 2026-08-22`.** `currentSpanContext()` synthesises `{ traceId, spanId: '', traceFlags: 1 }` from the
229
+ request context — a trace id this process minted, plus the header it would send onward. Handing
230
+ that to `Sampler.shouldSample` as a parent made `parentBasedRatioSampler` inherit a bit nobody sent,
231
+ so at ratio 0 a root span outside a request exported 0 and one inside exported 1 — and
232
+ `@ultimat3/http`'s `pipeline.ts` is `runWithContext` then `withSpan`, so **every HTTP root span was
233
+ exported at every ratio**. `startSpan` now narrows through `inboundParent()`: the trace id is
234
+ carried, the decision is not. It is the same discriminator `end()` already used to drop a synthetic
235
+ `parentSpanId`.
236
+
210
237
  The OTLP exporters are built, not wrapped, and the case is in
211
238
  [`docs/idea/18-build-vs-wrap.md`](../../docs/idea/18-build-vs-wrap.md): OTLP/HTTP JSON is `fetch`
212
239
  plus `JSON.stringify`, while `@opentelemetry/api` would put a SECOND `Span` type in the framework
@@ -216,6 +243,15 @@ plus `JSON.stringify`, while `@opentelemetry/api` would put a SECOND `Span` type
216
243
  non-`http/json` `OTEL_EXPORTER_OTLP_PROTOCOL` throw `X_OTLP_PROTOCOL_UNSUPPORTED` naming `:4318`.
217
244
  A boot that must not throw asks `tryOtlpEndpoint(signal)` first.
218
245
 
246
+ **Three OTLP variables, three codes, `As of 2026-08-22`** — `X_OTLP_ENDPOINT_INVALID`,
247
+ `X_OTLP_HEADERS_INVALID`, `X_OTLP_PROTOCOL_UNSUPPORTED`, one per variable an operator sets. The
248
+ headers one is not a duplicate of the endpoint one: `otlpHeaders` percent-decodes, so `%zz` in
249
+ `OTEL_EXPORTER_OTLP_HEADERS` used to take the process down with a bare `URIError` at exporter
250
+ construction, and raising the ENDPOINT code instead would send the first reader of
251
+ `x errors explain` to inspect a variable that is fine. A title is what an agent reads first, and an
252
+ accurate `cause:` does not rescue one that misdirects. The header **key** is in the cause, the fix
253
+ and `meta`; the **value** is in none of them — it is the collector's credential.
254
+
219
255
  `error-reporter.ts` is the same shape a third time: `ErrorReporter`, a no-op default, a memory
220
256
  reporter for tests, and a transport on the wire (`error-reporter-sentry.ts`, an optional separate
221
257
  export — the DSN is the app's typed env, never a constant here). `reportError` never throws and
@@ -247,13 +283,32 @@ bun run typecheck
247
283
  `markReady()` means **bound**, and readiness means **usable** — two different facts since
248
284
  `registerReadinessCheck(name, check)`. `/readyz` is ready only when the state is `ready` AND every
249
285
  named check passes, and `HealthReport.checks` carries them by name because "alert on check
250
- failures by check name" is not writable against a boolean. Checks are **synchronous** on purpose:
286
+ failures by check name" is not writable against a boolean. `HealthReport.registered` carries the
287
+ COUNT beside it, `As of 2026-08-22`: `checks: {}` reads identically for "every check passed" and
288
+ "nobody registered one", and only the second is a `/readyz` meaning no more than "the socket is
289
+ bound". Reported, never enforced — **an empty registry is still `ready`, and `/readyz` still
290
+ answers 200**: `Object.values({}).every(…)` is vacuously true, and that is deliberate so a role
291
+ with no dependency does not have to invent a check to boot. `registered` is the field a caller
292
+ reads to tell "all checks passed" from "there were none".
293
+
294
+ `readinessChecks()` builds its record through `Object.fromEntries`, never by assigning
295
+ `results[name]` — assignment to the one name `__proto__` sets the PROTOTYPE rather than adding a
296
+ key, so a check by that name disappeared from the report and a `failing` one answered 200. Checks are **synchronous** on purpose:
251
297
  a probe that awaits a network call turns a slow dependency into a wedged endpoint and a restart
252
298
  loop, so the owner of the dependency keeps a boolean fresh and this reads it. Liveness ignores
253
299
  them — a database outage that failed `/healthz` would restart the whole fleet into the same
254
300
  outage. The registration returns its unregister, same shape and same ownership rule as
255
301
  `onShutdown`; `readinessCheckCount()` is the leak probe.
256
302
 
303
+ **`drain()`'s memo is published BEFORE the first hook runs, `As of 2026-08-22`, and that ordering
304
+ is the whole of the function.** A hook may call back into `drain()` and one does — `handle.stop()`
305
+ in `@ultimat3/http` is `drain('manual')`, and an `accept` hook is exactly where a server stops
306
+ listening — while `settleWithin` invokes a hook SYNCHRONOUSLY. `drainPromise = (async () => …)()`
307
+ had therefore not assigned when the first hook ran: the re-entrant call read `undefined`, started a
308
+ second whole drain and recursed **~4,700 deep** until the stack ran out, every level swallowed by
309
+ `settleWithin` as `shutdown hook failed`. The guard and the registration are now one synchronous
310
+ step (`jobs`' `worker.ts` states the same rule), with the phases in `runDrain`.
311
+
257
312
  **The drain deadline is enforced, not merely computed, and there is no unbounded state.**
258
313
  `ShutdownReason.deadlineAt` was always handed to every hook and **no hook has ever read it** —
259
314
  `jobs`' worker awaits every in-flight job and `driver.close()`, `jobs`' scheduler awaits its round,
package/README.md CHANGED
@@ -322,6 +322,15 @@ nothing and still reports healthy.
322
322
  once has to keep it: a discarded one is a hook per `start()`, each retaining the resource it
323
323
  was going to drain, and the next drain runs every one of them against a torn-down copy.
324
324
  `shutdownHookCount()` is the test-only probe that makes the leak assertable.
325
+ - `registerReadinessCheck(name, check)` is what makes `/readyz` mean **usable** rather than
326
+ **bound**. `ReadinessCheck` is `() => boolean` and must stay synchronous — a probe that awaits its
327
+ dependency turns a slow dependency into a wedged endpoint and then a restart loop; keep a boolean
328
+ fresh and let the check read it. It returns an unregister. `HealthReport.checks` is a map of name
329
+ → `'ok' | 'failing'`, so "alert on check failures by check name" is writable.
330
+ - **`HealthReport.registered` is the third state.** `checks: {}` reads identically for "every check
331
+ passed" and "nobody registered one", and an **empty registry is still ready** — reported, never
332
+ enforced, so a role with no dependency does not have to invent a check to boot. Read `registered`
333
+ before trusting an empty `checks`.
325
334
  - Anything that opens a socket calls `markListening(server.url.origin)` and releases it on close.
326
335
  That is what tells the sealed test network a loopback request is this process, not egress.
327
336
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/core",
3
- "version": "7.0.0",
3
+ "version": "9.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",
@@ -0,0 +1,25 @@
1
+ // Single responsibility: the cache tiers' NAMES, in read order — the ladder itself is
2
+ // `@ultimat3/cache`'s. Tier 0 because that is the one place a tier-0 config declaration and a
3
+ // tier-1 implementation can both see; not `config.ts`, because `app.config.ts` consumes these
4
+ // names rather than owning them.
5
+
6
+ /**
7
+ * The rungs, near to far. **Order is load-bearing**: `sortTiers` in `@ultimat3/cache` orders a
8
+ * stack by index in this array, so a name's position here IS its distance from the request, and a
9
+ * name missing from it sorts to `-1` — ahead of the request memo.
10
+ *
11
+ * Spelled once because it was spelled twice and the two disagreed (issue #293): `app.config.ts`
12
+ * accepted `memo | lru | shared | isr | cdn` while the stack ordered `request-memo | lru | redis |
13
+ * cdn`, so `cache: { tiers: ['isr'] }` typechecked and selected nothing. `memo`/`request-memo` and
14
+ * `shared`/`redis` were one rung spelled twice; the ladder's spelling wins, because it is the one
15
+ * a `TierInvalidation`, a `TierFailure` and the `/_x` panel already report.
16
+ *
17
+ * **`isr` is not here and is not a cache tier.** It is a `RenderMode` (`route-vocabulary.ts`) — a
18
+ * per-route rendering decision, revalidated by a tag bust — and `@ultimat3/cache` has no ISR store
19
+ * to build: `'isr'` appears nowhere in `packages/cache/src` except one invalidation label. Serving
20
+ * ISR is `render: 'isr'` on the route, never a rung of this ladder.
21
+ */
22
+ export const CACHE_TIERS = ['request-memo', 'lru', 'redis', 'cdn'] as const;
23
+
24
+ /** Derived from the array rather than written twice, so the pair cannot disagree. */
25
+ export type CacheTierName = (typeof CACHE_TIERS)[number];
package/src/config.ts CHANGED
@@ -2,6 +2,10 @@
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
+ // Same rule for the same reason: `app.config.ts` CONSUMES the cache tier names, it does not own
6
+ // them. Declaring them here is what let `cache.tiers` and the ladder `@ultimat3/cache` orders by
7
+ // drift into two vocabularies with no map between them (issue #293).
8
+ import { CACHE_TIERS, type CacheTierName } from './cache-vocabulary';
5
9
  import { ConfigInvalidError } from './errors';
6
10
  import { ROLES, type Role } from './roles';
7
11
  // `app.config.ts` CONSUMES the route vocabulary; it does not own it. Declaring `OfflineStrategy`
@@ -11,7 +15,6 @@ import type { OfflineStrategy } from './route-vocabulary';
11
15
  import { isIanaZoneName } from './time-zone-name';
12
16
 
13
17
  export type ThemeMode = 'light' | 'dark' | 'system';
14
- export type CacheTier = 'memo' | 'lru' | 'shared' | 'isr' | 'cdn';
15
18
  export type RealtimeTier = 'channels' | 'live-queries' | 'local-first';
16
19
  export type RealtimeTransport = 'memory' | 'nats' | 'redis';
17
20
 
@@ -22,38 +25,30 @@ export interface ThemeConfig {
22
25
  }
23
26
 
24
27
  /**
25
- * Where a browser that failed `auth: 'required'` is sent, and where it lands afterwards.
28
+ * Where a browser that failed `auth: 'required'` is sent.
26
29
  *
27
30
  * `signInPath: null` is the default and the redirect stays off until an app names its page: the
28
31
  * framework may not invent one of its app's routes, and an app that spells it `/login` would send
29
32
  * every unauthenticated visitor to a 404. Null means the visitor gets the problem document — the
30
33
  * right answer for an agent, and what a browser got in production until this existed.
34
+ *
35
+ * `afterSignInPath` was removed 2026-08 for the reason `urlEnv`, `poolSize` and `schema` were
36
+ * (below): accepted, defaulted and merged here, and read by NO file — `dummy/social-media-clone`
37
+ * set `/dashboard` and got whatever its sign-in route did on its own. The landing path belongs to
38
+ * the app's sign-in route, which is the only code that can honour it.
31
39
  */
32
40
  export interface AuthConfig {
33
41
  readonly signInPath: string | null;
34
- /**
35
- * Where sign-in lands when there is nowhere to return to, or `?next=` is not same-origin.
36
- *
37
- * **Consulted by nothing, `As of 2026-08.`** Accepted, defaulted and merged here and read by no
38
- * file in the repo — `dummy/social-media-clone/app.config.ts` sets `/dashboard` and gets
39
- * whatever the sign-in route does on its own. Same shape `urlEnv`, `poolSize` and `schema` were
40
- * deleted for below; this one is not deleted yet only because its writer is a tracked app's
41
- * config, so removing the key and the line that sets it is one commit across two file sets.
42
- */
43
- readonly afterSignInPath: string;
44
42
  }
45
43
 
44
+ /**
45
+ * `installPrompt` was removed 2026-08, same rule: `@ultimat3/pwa`'s `createInstallController` is
46
+ * real and complete, nothing ever threaded the flag into it, and both tracked apps plus every
47
+ * scaffolded app set a switch with no wire. Call the controller from your own affordance instead.
48
+ */
46
49
  export interface PwaConfig {
47
50
  readonly enabled: boolean;
48
51
  readonly offline: OfflineStrategy;
49
- /**
50
- * **Consulted by nothing, `As of 2026-08.`** `wiki/Configuration.md` describes it as "render
51
- * your own install affordance from the deferred event", both tracked apps set it, and
52
- * `x new`'s scaffold writes it into every generated app — and no file reads it.
53
- * `@ultimat3/pwa`'s `install.ts` is real and complete; nothing threads this flag into it.
54
- * Delete the key or thread it; leaving it is a switch with no wire.
55
- */
56
- readonly installPrompt: boolean;
57
52
  readonly backgroundSync: boolean;
58
53
  readonly push: boolean;
59
54
  }
@@ -78,11 +73,25 @@ export interface DatabaseConfig {
78
73
  readonly ssl: boolean;
79
74
  }
80
75
 
76
+ /**
77
+ * No `CacheTier`. It was a SECOND spelling of the ladder — `memo | lru | shared | isr | cdn`
78
+ * against `@ultimat3/cache`'s `request-memo | lru | redis | cdn` — with nothing mapping one onto
79
+ * the other, so `cache: { tiers: ['isr'] }` typechecked and selected nothing. Deleted 2026-08-22
80
+ * in favour of `CacheTierName`, which is the ladder's own names and the only ones `sortTiers` can
81
+ * place. It was also the second exported type called `CacheTier` in the tree; the other is
82
+ * `@ultimat3/cache`'s tier INTERFACE, which is the one every implementation names.
83
+ *
84
+ * No `driver` and no `urlEnv` either, deleted 2026-08-22 and for the same reason one rung further
85
+ * up: `tiers` is what BUILDS the ladder (`packages/cli/src/dev-cache.ts`), so `driver: 'redis'`
86
+ * beside `tiers: ['request-memo', 'lru']` was a second selector that selected nothing — the shape
87
+ * `examples/dummy/app.config.ts` shipped. `urlEnv` was `database.urlEnv` verbatim: the Redis tier
88
+ * reads the literal `REDIS_URL`, so `urlEnv: 'MY_REDIS'` made nothing read `MY_REDIS`. Which rungs
89
+ * exist is `cache.tiers` and only that; a rung the environment cannot supply refuses the boot.
90
+ */
81
91
  export interface CacheConfig {
82
- readonly driver: 'memory' | 'redis';
83
- readonly urlEnv: string | undefined;
84
92
  readonly defaultTtlMs: number;
85
- readonly tiers: readonly CacheTier[];
93
+ /** Order is fixed by `TIER_ORDER`; listing order here selects rungs, it does not rank them. */
94
+ readonly tiers: readonly CacheTierName[];
86
95
  }
87
96
 
88
97
  export interface JobsConfig {
@@ -123,18 +132,16 @@ export interface McpConfig {
123
132
  readonly path: string;
124
133
  }
125
134
 
135
+ /**
136
+ * No `modelEnv`. It named the env KEY holding the model id, "so no model string is baked into the
137
+ * image" — and its only reader was this file's own merge, copying input to output. Nothing
138
+ * consumed the merged value, so `modelEnv: 'ANTHROPIC_MODEL'` selected no model: `@ultimat3/ai`
139
+ * reads env for API KEYS only, and the model is `request.model ?? DEFAULT_MODEL`, a compile-time
140
+ * constant in `models.ts`. The exact thing the key existed to prevent is what it delivered.
141
+ * Deleted 2026-08 — pass `model` on the request, or read your own env key and pass it.
142
+ */
126
143
  export interface AiConfig {
127
144
  readonly mcp: McpConfig;
128
- /**
129
- * Env key for the model id, so no model string is baked into the image — **an intention, not a
130
- * behaviour, `As of 2026-08`.** The only read of it in the repo is the merge two hundred lines
131
- * below, which copies it from input to output; nothing consumes the merged value, so
132
- * `examples/dummy`'s `modelEnv: 'ANTHROPIC_MODEL'` selects no model. `@ultimat3/ai` reads env
133
- * for API KEYS only (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`); the model is
134
- * `request.model ?? DEFAULT_MODEL`, a compile-time constant in `models.ts`. So the exact thing
135
- * this key exists to prevent — a model string baked into the image — is what actually happens.
136
- */
137
- readonly modelEnv: string | undefined;
138
145
  }
139
146
 
140
147
  export interface AppConfig {
@@ -156,7 +163,8 @@ export interface AppConfig {
156
163
 
157
164
  type Input<T> = { readonly [K in keyof T]?: T[K] | undefined };
158
165
 
159
- export interface AiConfigInput extends Input<Omit<AiConfig, 'mcp'>> {
166
+ /** `mcp` is the only member, and it is NESTED — `Input<AiConfig>` would make it all-or-nothing. */
167
+ export interface AiConfigInput {
160
168
  readonly mcp?: Input<McpConfig> | undefined;
161
169
  }
162
170
 
@@ -224,17 +232,11 @@ function defaults(name: string): Omit<AppConfig, 'name'> {
224
232
  defaultTimeZone: 'UTC',
225
233
  defaultCurrency: 'USD',
226
234
  theme: { defaultMode: 'system', tokens: {} },
227
- auth: { signInPath: null, afterSignInPath: '/' },
228
- pwa: {
229
- enabled: false,
230
- offline: 'network-only',
231
- installPrompt: false,
232
- backgroundSync: false,
233
- push: false,
234
- },
235
+ auth: { signInPath: null },
236
+ pwa: { enabled: false, offline: 'network-only', backgroundSync: false, push: false },
235
237
  roles: [...ROLES],
236
238
  database: { driver: 'postgres', ssl: false },
237
- cache: { driver: 'memory', urlEnv: undefined, defaultTtlMs: 60_000, tiers: ['memo', 'lru'] },
239
+ cache: { defaultTtlMs: 60_000, tiers: ['request-memo', 'lru'] },
238
240
  jobs: {
239
241
  queues: [`${name}-default`],
240
242
  concurrency: 8,
@@ -243,7 +245,7 @@ function defaults(name: string): Omit<AppConfig, 'name'> {
243
245
  visibilityTimeoutMs: 30_000,
244
246
  },
245
247
  realtime: { enabled: false, tier: 'channels', transport: 'memory', urlEnv: undefined },
246
- ai: { mcp: { expose: true, path: '/mcp' }, modelEnv: undefined },
248
+ ai: { mcp: { expose: true, path: '/mcp' } },
247
249
  };
248
250
  }
249
251
 
@@ -259,10 +261,21 @@ const BASE_FIX = 'edit app.config.ts to fix the fields named in cause, then run:
259
261
  const TIMEZONE_FIX =
260
262
  "set defaultTimeZone to an Area/Location name, or UTC — list every accepted one with bun -e \"console.log(Intl.supportedValuesOf('timeZone').join('\\n'))\" — where a legacy single-label name swaps mechanically (Japan → Asia/Tokyo, GB → Europe/London, Universal → UTC), while an abbreviation or numeric offset (CET, EST5EDT, +01:00) carries no DST rule and has no replacement, so name the city whose clock you mean (Europe/Paris, America/New_York)";
261
263
 
264
+ /**
265
+ * Appended only when a tier name is what failed, and it names the rename rather than the rule: the
266
+ * three refused spellings are the ones 8.0.0 accepted, and two of them have a mechanical
267
+ * replacement while `isr` has none — it is a `RenderMode`, and no cache tier ever served it.
268
+ */
269
+ const CACHE_TIER_FIX =
270
+ "in app.config.ts, rewrite cache.tiers with the rung names the ladder serves — request-memo, lru, redis, cdn — where memo becomes request-memo and shared becomes redis, and isr is dropped: it is a render mode, so move it to render: 'isr' on the routes that want it";
271
+
262
272
  function validate(config: AppConfig): void {
263
273
  const issues: string[] = [];
264
274
  // Zero or one entry: the zone's own remedy, carried only when the zone is what failed.
265
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.
278
+ const tierFix: string[] = [];
266
279
 
267
280
  if (!NAME_RE.test(config.name)) {
268
281
  issues.push(`name "${config.name}" must match ${String(NAME_RE)}`);
@@ -292,8 +305,13 @@ function validate(config: AppConfig): void {
292
305
  if (config.realtime.transport !== 'memory' && config.realtime.urlEnv === undefined) {
293
306
  issues.push(`realtime.transport "${config.realtime.transport}" requires realtime.urlEnv`);
294
307
  }
295
- if (config.cache.driver === 'redis' && config.cache.urlEnv === undefined) {
296
- issues.push('cache.driver "redis" requires cache.urlEnv');
308
+ // A rung the ladder cannot build is the defect this key had: `sortTiers` places a name by its
309
+ // index in `CACHE_TIERS`, and a name missing from it sorts to `-1` — AHEAD of the request memo.
310
+ // So an unknown tier is refused at boot rather than silently ignored or silently placed first.
311
+ for (const tier of config.cache.tiers) {
312
+ if (CACHE_TIERS.includes(tier)) continue;
313
+ issues.push(`cache.tiers contains "${tier}", which is not one of ${CACHE_TIERS.join(', ')}`);
314
+ if (tierFix.length === 0) tierFix.push(CACHE_TIER_FIX);
297
315
  }
298
316
 
299
317
  if (issues.length > 0) {
@@ -301,7 +319,7 @@ function validate(config: AppConfig): void {
301
319
  cause: issues.join('; '),
302
320
  // The generic instruction goes LAST so the fix line still ends in a command that can be
303
321
  // pasted — a trailing `.` after `x verify` is a command nobody can run.
304
- fix: [...zoneFix, BASE_FIX].join('. '),
322
+ fix: [...zoneFix, ...tierFix, BASE_FIX].join('. '),
305
323
  meta: { issues },
306
324
  });
307
325
  }
@@ -337,10 +355,7 @@ export function defineConfig(
337
355
  cache: section(base.cache, merged.cache),
338
356
  jobs: section(base.jobs, merged.jobs),
339
357
  realtime: section(base.realtime, merged.realtime),
340
- ai: {
341
- mcp: section(base.ai.mcp, merged.ai?.mcp),
342
- modelEnv: merged.ai?.modelEnv ?? base.ai.modelEnv,
343
- },
358
+ ai: { mcp: section(base.ai.mcp, merged.ai?.mcp) },
344
359
  };
345
360
 
346
361
  validate(config);
package/src/context.ts CHANGED
@@ -70,7 +70,13 @@ export interface CtxInit {
70
70
  readonly services?: ServiceBag | undefined;
71
71
  }
72
72
 
73
- export type CtxPatch = Omit<CtxInit, 'requestId'>;
73
+ /**
74
+ * Neither id a child may change. `requestId` because one request is one request however many
75
+ * scopes it opens; `buildId` because a child context is the same DEPLOY — `withChildContext`
76
+ * has always forwarded the parent's, so accepting the key was an option that read as honoured and
77
+ * was dropped in silence. Pinned in `type-pins.ts`.
78
+ */
79
+ export type CtxPatch = Omit<CtxInit, 'requestId' | 'buildId'>;
74
80
 
75
81
  /**
76
82
  * `async-context.ts` owns why this is a lazily-opened seam rather than a module-scope
@@ -48,12 +48,16 @@ function decimalOf(value: unknown): Decimal | undefined {
48
48
  * a 38-digit `numeric` orders by its digits rather than by whatever a `Number` rounded it to.
49
49
  */
50
50
  function compare(left: Decimal, right: Decimal): number {
51
- if (left.negative !== right.negative) return left.negative ? -1 : 1;
52
51
  const width = Math.max(left.fraction.length, right.fraction.length);
53
52
  const scaled = (value: Decimal): bigint =>
54
53
  BigInt(`${value.whole}${value.fraction.padEnd(width, '0')}`);
55
54
  const first = scaled(left);
56
55
  const second = scaled(right);
56
+ // Magnitude BEFORE sign, because a `numeric` has exactly one zero: `select '-0'::numeric =
57
+ // '0'::numeric` is true, and so is `'-0.00' = '0'`. Comparing the sign first answered `-1` for
58
+ // that pair and cut a keyset page boundary between two rows the database calls equal.
59
+ if (first === 0n && second === 0n) return 0;
60
+ if (left.negative !== right.negative) return left.negative ? -1 : 1;
57
61
  // Never a subtraction: the difference between two `bigint`s is exact and the return type is a
58
62
  // `number`, which cannot hold it.
59
63
  const order = first < second ? -1 : first > second ? 1 : 0;
@@ -53,6 +53,9 @@ const CORE_CODE_TITLES = {
53
53
  X_NO_CONTEXT: 'no request context is active',
54
54
  X_NOT_IMPLEMENTED: 'this driver does not implement the requested feature',
55
55
  X_OTLP_ENDPOINT_INVALID: 'the OTLP collector endpoint is missing or malformed',
56
+ // Its own code rather than the endpoint's, because a title is what an agent reads first:
57
+ // `x errors explain X_OTLP_ENDPOINT_INVALID` would send it to inspect a variable that is fine.
58
+ X_OTLP_HEADERS_INVALID: 'OTEL_EXPORTER_OTLP_HEADERS is malformed',
56
59
  X_OTLP_PROTOCOL_UNSUPPORTED: 'the OTLP protocol requested is not OTLP/HTTP JSON',
57
60
  X_READINESS_CHECK_DUPLICATE: 'a readiness check name is registered twice',
58
61
  X_REGISTRAR_CONFLICT: 'two different registrars are loaded for one primitive kind',
@@ -252,17 +252,17 @@ 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, character-for-character duplicate of `describeValue` in
256
- * `packages/schema/src/describe-value.ts`, for the reason `SCHEMA_ERROR_CODE_TITLES` is one:
257
- * `@ultimat3/schema` is tier 0 alongside this package, so neither may import the other. Keep the
258
- * two identical; changing one alone is the bug.
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
259
  */
260
260
  export function describeValue(value: unknown): string {
261
261
  if (value === undefined) return 'undefined';
262
262
  if (value === null) return 'null';
263
263
  switch (typeof value) {
264
264
  case 'string':
265
- return countOf(value.length, 'string', 'character');
265
+ return countOf(charCount(value), 'string', 'character');
266
266
  case 'number':
267
267
  return describeNumber(value);
268
268
  case 'boolean':
@@ -294,3 +294,15 @@ function countOf(size: number, noun: string, unit: string): string {
294
294
  const article = noun === 'array' ? 'an' : 'a';
295
295
  return `${article} ${noun} of ${size} ${unit}${size === 1 ? '' : 's'}`;
296
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.
303
+ */
304
+ const HAS_SURROGATE = /[\uD800-\uDBFF]/;
305
+
306
+ function charCount(value: string): number {
307
+ return HAS_SURROGATE.test(value) ? [...value].length : value.length;
308
+ }
@@ -42,6 +42,7 @@ export {
42
42
  REDACTED,
43
43
  redactKeys,
44
44
  setLoggerContextFields,
45
+ setLogStream,
45
46
  } from '../logger';
46
47
  export type {
47
48
  Counter,
@@ -88,6 +89,7 @@ export {
88
89
  OTLP_PROTOCOL_KEY,
89
90
  OTLP_SCOPE,
90
91
  OtlpEndpointInvalidError,
92
+ OtlpHeadersInvalidError,
91
93
  OtlpProtocolUnsupportedError,
92
94
  otlpAttributes,
93
95
  otlpEndpoint,
@@ -0,0 +1,47 @@
1
+ // Single responsibility: a byte count as the short, machine-ish string an error message carries.
2
+ //
3
+ // Tier 0 because two tier-4 packages need exactly this and neither may import the other:
4
+ // `@ultimat3/render`'s `X_BUDGET_EXCEEDED` cause and `@ultimat3/pwa`'s precache warning. Each kept
5
+ // its own copy and they had diverged — render's stopped at `kb`, so one 5 MiB route read `5120kb`
6
+ // in the budget error and `5mb` in the warning about the same bytes.
7
+
8
+ /** 1024-based, because every producer here counts bundle bytes, which tooling reports in KiB. */
9
+ const STEP = 1024;
10
+
11
+ /**
12
+ * Ascending, so the index into it IS the power of `STEP`. `gb` is the last rung on purpose: a
13
+ * precache or a route bundle past a terabyte is a bug in the caller, not a unit this should grow.
14
+ */
15
+ const UNITS = ['b', 'kb', 'mb', 'gb'] as const;
16
+
17
+ const round1 = (value: number): number => Math.round(value * 10) / 10;
18
+
19
+ /**
20
+ * A size a message can state — `1023b`, `4.5kb`, `5mb`, `1.2gb`.
21
+ *
22
+ * Not `@ultimat3/ui`'s `formatBytes(bytes, locale)`, which is `Intl`-formatted, DECIMAL (kB = 1000
23
+ * B, because that is what `Intl`'s unit means) and for a human reading a file picker. This one is
24
+ * for an error's `cause:`, where the number has to line up with a bundler's own KiB figures and
25
+ * must not change with the reader's locale.
26
+ *
27
+ * A negative or non-finite input answers `0b` rather than `-5b` or `NaNb`: axiom 4 says an error is
28
+ * an instruction, and `NaNb` instructs nobody. A size is never negative, so the input was already
29
+ * wrong by the time it arrived.
30
+ */
31
+ export const formatBytes = (bytes: number): string => {
32
+ if (!Number.isFinite(bytes) || bytes <= 0) return `0${UNITS[0]}`;
33
+ let value = bytes;
34
+ let index = 0;
35
+ while (index < UNITS.length - 1 && value >= STEP) {
36
+ value /= STEP;
37
+ index += 1;
38
+ }
39
+ // One more rung when ROUNDING crosses the boundary the raw value did not: 1048575 is under a
40
+ // mebibyte, but one decimal place renders it `1024kb` — a number that disagrees with its own
41
+ // unit, the same class of bug as render's missing `mb` branch.
42
+ if (index < UNITS.length - 1 && round1(value) >= STEP) {
43
+ value /= STEP;
44
+ index += 1;
45
+ }
46
+ return `${round1(value)}${UNITS[index] ?? 'b'}`;
47
+ };
package/src/index.ts CHANGED
@@ -32,6 +32,7 @@ export {
32
32
  export { APP_VERSION_KEY, appVersion, DEFAULT_APP_VERSION } from './app-version';
33
33
  export { assert, assertNever, type InvariantOptions, invariant } from './assert';
34
34
  export { type AsyncContext, asyncContext } from './async-context';
35
+ export { CACHE_TIERS, type CacheTierName } from './cache-vocabulary';
35
36
  export { canonicalJson, fingerprint } from './canonical-json';
36
37
  export { type Clock, type FrozenClock, frozenClock, systemClock } from './clock';
37
38
  export type {
@@ -42,7 +43,6 @@ export type {
42
43
  AppConfigOverlay,
43
44
  AuthConfig,
44
45
  CacheConfig,
45
- CacheTier,
46
46
  DatabaseConfig,
47
47
  JobsConfig,
48
48
  McpConfig,
@@ -268,6 +268,7 @@ export {
268
268
  OTLP_PROTOCOL_KEY,
269
269
  OTLP_SCOPE,
270
270
  OtlpEndpointInvalidError,
271
+ OtlpHeadersInvalidError,
271
272
  OtlpProtocolUnsupportedError,
272
273
  OVERFLOW_ATTRIBUTE,
273
274
  otlpAttributes,
@@ -303,6 +304,7 @@ export {
303
304
  sentryErrorReporter,
304
305
  serviceResource,
305
306
  setLoggerContextFields,
307
+ setLogStream,
306
308
  startMetricExport,
307
309
  startSpan,
308
310
  traceparent,
@@ -369,6 +371,7 @@ export {
369
371
  writeMasterKeyFile,
370
372
  writeSecretsFile,
371
373
  } from './exports/secrets';
374
+ export { formatBytes } from './format-bytes';
372
375
  export type { Brand, Id } from './ids';
373
376
  export {
374
377
  isSpanId,
@@ -459,17 +462,19 @@ export {
459
462
  SHUTDOWN_PHASES,
460
463
  shutdownHookCount,
461
464
  } from './lifecycle';
462
- export {
463
- isSelfOrigin,
464
- listeningOrigins,
465
- markListening,
466
- resetListeners,
467
- } from './listeners';
465
+ export { isSelfOrigin, listeningOrigins, markListening, resetListeners } from './listeners';
468
466
  export { isMcpExposed, type McpExposureDeclaration } from './mcp-exposure';
467
+ export { nearestName } from './nearest-name';
469
468
  export { type CappedBody, readWithinLimit } from './read-capped';
470
- export type { ModuleRegistrar, PrimitiveKind, RegisteredPrimitive } from './registrar';
469
+ export type {
470
+ ModuleRegistrar,
471
+ PrimitiveFactory,
472
+ PrimitiveKind,
473
+ RegisteredPrimitive,
474
+ } from './registrar';
471
475
  export {
472
476
  hasPrimitiveRegistrar,
477
+ PRIMITIVE_FACTORIES,
473
478
  PRIMITIVE_KINDS,
474
479
  primitiveRegistrar,
475
480
  registerPrimitiveRegistrar,
package/src/lifecycle.ts CHANGED
@@ -82,6 +82,14 @@ export interface HealthReport {
82
82
  readonly buildId: string;
83
83
  /** Named, because "alert on check failures BY CHECK NAME" is not writable against a boolean. */
84
84
  readonly checks: Readonly<Record<string, ReadinessStatus>>;
85
+ /**
86
+ * How many checks are registered. `checks: {}` reads identically for "every check passed" and
87
+ * "nobody registered one", and only the second is a `/readyz` that means no more than "the
88
+ * socket is bound" — which is what the chart's and compose's healthchecks route traffic on.
89
+ * Reported rather than enforced: an empty registry is still ready, so a role that genuinely has
90
+ * no dependency does not have to invent a check to boot.
91
+ */
92
+ readonly registered: number;
85
93
  }
86
94
 
87
95
  export interface HealthPayload {
@@ -192,18 +200,25 @@ function report(level: 'info' | 'warn' | 'error', message: string, fields: LogFi
192
200
  }
193
201
  }
194
202
 
195
- /** Every check, run now, by name. A check that throws is `failing` — never an unhandled error. */
203
+ /**
204
+ * Every check, run now, by name. A check that throws is `failing` — never an unhandled error.
205
+ *
206
+ * Built through `Object.fromEntries`, never by assigning `results[name]`: assignment to the one
207
+ * name `__proto__` sets the PROTOTYPE instead of adding a key, so that check vanished from the
208
+ * report, `ready` was computed over an empty object — vacuously true — and a failing check
209
+ * answered 200. `fromEntries` defines own properties and has no such name.
210
+ */
196
211
  export function readinessChecks(): Readonly<Record<string, ReadinessStatus>> {
197
- const results: Record<string, ReadinessStatus> = {};
212
+ const results: [string, ReadinessStatus][] = [];
198
213
  for (const [name, check] of readiness) {
199
214
  try {
200
- results[name] = check() ? 'ok' : 'failing';
215
+ results.push([name, check() ? 'ok' : 'failing']);
201
216
  } catch (thrown) {
202
- results[name] = 'failing';
217
+ results.push([name, 'failing']);
203
218
  report('warn', 'readiness check threw', { check: name, error: thrown });
204
219
  }
205
220
  }
206
- return results;
221
+ return Object.fromEntries(results);
207
222
  }
208
223
 
209
224
  export function inflightCount(): number {
@@ -328,43 +343,62 @@ async function runPhase(phase: ShutdownPhase, reason: ShutdownReason): Promise<v
328
343
  }
329
344
  }
330
345
 
331
- /** Idempotent: concurrent signals join the same drain. */
346
+ /** The three phases, in order, under one budget. Never rejects — `drain()` depends on that. */
347
+ async function runDrain(signal: string, reason: ShutdownReason): Promise<void> {
348
+ try {
349
+ report('info', 'draining', { signal, deadlineMs, inflight });
350
+ await runPhase('accept', reason);
351
+
352
+ // Real monotonic, like `deadlineAt` itself: `waitForIdle` sleeps on a real `setTimeout`, and
353
+ // a budget read off an injected clock is a number that timer will never honour.
354
+ const remaining = Math.max(0, reason.deadlineAt - systemClock.monotonic());
355
+ const idle = await waitForIdle(remaining);
356
+ if (!idle) {
357
+ report('warn', 'X_SHUTDOWN_TIMEOUT', {
358
+ code: 'X_SHUTDOWN_TIMEOUT',
359
+ cause: `${inflight} in-flight operations still running after ${deadlineMs}ms`,
360
+ fix: 'raise the budget past the slowest handler — configureLifecycle({ deadlineMs: 600_000 }) for a 10-minute one — and set terminationGracePeriodSeconds to at least as many seconds, or shorten the handler',
361
+ });
362
+ }
363
+
364
+ await runPhase('inflight', reason);
365
+ await runPhase('close', reason);
366
+ } catch (thrown) {
367
+ // Nothing above should reach here — every hook is caught by `settleWithin` and every line
368
+ // goes through `report`. If something does, the drain still ENDS: a rejected `drainPromise`
369
+ // is a memo that re-rejects for every later caller and an unhandled rejection that kills the
370
+ // process mid-drain, which is strictly worse than a drain that finished badly and said so.
371
+ report('error', 'drain failed', { signal, error: thrown });
372
+ } finally {
373
+ state = 'stopped';
374
+ }
375
+ report('info', 'stopped', { signal });
376
+ }
377
+
378
+ /**
379
+ * Idempotent: concurrent signals join the same drain, and so does a RE-ENTRANT one.
380
+ *
381
+ * The memo is published before `runDrain` is called, and that ordering is the whole of this
382
+ * function. A hook may call back in here — `handle.stop()` in `@ultimat3/http` is `drain('manual')`
383
+ * and an `accept` hook is exactly where a server stops listening — and `settleWithin` invokes a
384
+ * hook SYNCHRONOUSLY, so the old `drainPromise = (async () => …)()` had not assigned yet when the
385
+ * first hook ran: the re-entrant call saw `undefined`, started a second whole drain, and recursed
386
+ * ~4,700 deep until the stack ran out, every level swallowed by `settleWithin` as
387
+ * `shutdown hook failed`. Same rule as `packages/jobs/src/worker.ts` — guard and registration in
388
+ * one synchronous step.
389
+ */
332
390
  export function drain(signal = 'manual'): Promise<void> {
333
391
  if (drainPromise !== undefined) return drainPromise;
334
392
  state = 'draining';
335
393
  const reason: ShutdownReason = { signal, deadlineAt: systemClock.monotonic() + deadlineMs };
336
-
337
- drainPromise = (async () => {
338
- try {
339
- report('info', 'draining', { signal, deadlineMs, inflight });
340
- await runPhase('accept', reason);
341
-
342
- // Real monotonic, like `deadlineAt` itself: `waitForIdle` sleeps on a real `setTimeout`, and
343
- // a budget read off an injected clock is a number that timer will never honour.
344
- const remaining = Math.max(0, reason.deadlineAt - systemClock.monotonic());
345
- const idle = await waitForIdle(remaining);
346
- if (!idle) {
347
- report('warn', 'X_SHUTDOWN_TIMEOUT', {
348
- code: 'X_SHUTDOWN_TIMEOUT',
349
- cause: `${inflight} in-flight operations still running after ${deadlineMs}ms`,
350
- fix: 'raise the budget past the slowest handler — configureLifecycle({ deadlineMs: 600_000 }) for a 10-minute one — and set terminationGracePeriodSeconds to at least as many seconds, or shorten the handler',
351
- });
352
- }
353
-
354
- await runPhase('inflight', reason);
355
- await runPhase('close', reason);
356
- } catch (thrown) {
357
- // Nothing above should reach here — every hook is caught by `settleWithin` and every line
358
- // goes through `report`. If something does, the drain still ENDS: a rejected `drainPromise`
359
- // is a memo that re-rejects for every later caller and an unhandled rejection that kills the
360
- // process mid-drain, which is strictly worse than a drain that finished badly and said so.
361
- report('error', 'drain failed', { signal, error: thrown });
362
- } finally {
363
- state = 'stopped';
364
- }
365
- report('info', 'stopped', { signal });
366
- })();
367
-
394
+ let published!: () => void;
395
+ drainPromise = new Promise<void>((resolve) => {
396
+ published = resolve;
397
+ });
398
+ // Both settle paths, for the reason `installSignalHandlers` gives below: `runDrain` cannot
399
+ // reject today — that is its `try/finally`, not luck — and a rejected memo would re-reject for
400
+ // every later caller and end the process the drain was trying to end cleanly.
401
+ void runDrain(signal, reason).then(published, published);
368
402
  return drainPromise;
369
403
  }
370
404
 
@@ -410,6 +444,7 @@ export function healthReport(): HealthReport {
410
444
  inflight,
411
445
  buildId: process.env['BUILD_ID'] ?? 'dev',
412
446
  checks,
447
+ registered: readiness.size,
413
448
  };
414
449
  }
415
450
 
package/src/logger.ts CHANGED
@@ -101,8 +101,28 @@ export function setLoggerContextFields(provider: () => LogFields | undefined): v
101
101
  contextFields = provider;
102
102
  }
103
103
 
104
+ /**
105
+ * Where a line with no explicit writer lands, for everything below `error`. A fact about the
106
+ * PROCESS and not about the line: a container's stdout IS its log stream (12-factor), while a
107
+ * CLI's stdout is the answer it was asked for. `x db migrate --json` printed the boot logger's
108
+ * `ultimate migrate applied` and then the command's own JSON to fd 1, so a caller doing what
109
+ * `--json` exists for — parsing the output — raised on the second object.
110
+ */
111
+ let logStream: 'stdout' | 'stderr' = 'stdout';
112
+
113
+ /**
114
+ * Send everything below `error` to stderr, or back to stdout. The process's own call, made once at
115
+ * entry: a per-line choice would be the second logging path axiom 1 refuses, and a per-logger one
116
+ * already exists as `LoggerOptions.writer` — what had no seam is the module-scope `logger`, which
117
+ * is the one `serve.ts` and every boot path write through.
118
+ */
119
+ export function setLogStream(stream: 'stdout' | 'stderr'): void {
120
+ logStream = stream;
121
+ }
122
+
104
123
  function defaultWriter(line: string, level: LogLevel): void {
105
- const stream = LEVEL_WEIGHT[level] >= LEVEL_WEIGHT.error ? process.stderr : process.stdout;
124
+ const toStderr = logStream === 'stderr' || LEVEL_WEIGHT[level] >= LEVEL_WEIGHT.error;
125
+ const stream = toStderr ? process.stderr : process.stdout;
106
126
  stream.write(`${line}\n`);
107
127
  }
108
128
 
@@ -0,0 +1,45 @@
1
+ // Single responsibility: the declared name a mistyped one most likely meant, so any error can lead
2
+ // with the real one. It lives in core because three packages need the same answer — `@ultimat3/cli`
3
+ // for an unknown command, flag or positional, `@ultimat3/policy` for an unknown permission — and
4
+ // two copies of one cutoff are two suggestions for one typo.
5
+
6
+ /**
7
+ * Levenshtein distance. A grid rather than two rolling rows because `noUncheckedIndexedAccess`
8
+ * makes every read an `?? 0`, and one `at()` reads better than four of them.
9
+ */
10
+ const distance = (a: string, b: string): number => {
11
+ const rows = a.length + 1;
12
+ const cols = b.length + 1;
13
+ const grid: number[] = new Array<number>(rows * cols).fill(0);
14
+ const at = (r: number, c: number): number => grid[r * cols + c] ?? 0;
15
+ for (let r = 0; r < rows; r += 1) grid[r * cols] = r;
16
+ for (let c = 0; c < cols; c += 1) grid[c] = c;
17
+ for (let r = 1; r < rows; r += 1) {
18
+ for (let c = 1; c < cols; c += 1) {
19
+ const cost = a[r - 1] === b[c - 1] ? 0 : 1;
20
+ grid[r * cols + c] = Math.min(at(r - 1, c) + 1, at(r, c - 1) + 1, at(r - 1, c - 1) + cost);
21
+ }
22
+ }
23
+ return at(rows - 1, cols - 1);
24
+ };
25
+
26
+ /** Past this many edits the "suggestion" is a different word, and a wrong lead is worse than none. */
27
+ const MAX_EDITS = 3;
28
+
29
+ /**
30
+ * The nearest candidate within `MAX_EDITS`, or `undefined` when nothing is close enough. Ties keep
31
+ * the FIRST candidate, which is the order the caller declared them in — `definePermissions([...])`
32
+ * and a `CommandSpec` list are both authored orders, and a stable answer is what lets a test pin one.
33
+ */
34
+ export const nearestName = (input: string, candidates: readonly string[]): string | undefined => {
35
+ let best: string | undefined;
36
+ let bestScore = MAX_EDITS + 1;
37
+ for (const candidate of candidates) {
38
+ const score = distance(input, candidate);
39
+ if (score < bestScore) {
40
+ best = candidate;
41
+ bestScore = score;
42
+ }
43
+ }
44
+ return best;
45
+ };
package/src/otlp.ts CHANGED
@@ -15,6 +15,20 @@ export class OtlpEndpointInvalidError extends UltimateError {
15
15
  }
16
16
  }
17
17
 
18
+ /**
19
+ * Separate from `OtlpEndpointInvalidError` on purpose. The endpoint code's title says the ENDPOINT
20
+ * is missing or malformed, so raising it for a bad header escape sends the first reader of
21
+ * `x errors explain` to inspect `OTEL_EXPORTER_OTLP_ENDPOINT` — a variable that is fine. An
22
+ * accurate `cause:` does not rescue a title that misdirects.
23
+ */
24
+ export class OtlpHeadersInvalidError extends UltimateError {
25
+ static readonly code = 'X_OTLP_HEADERS_INVALID';
26
+ override readonly name = 'OtlpHeadersInvalidError';
27
+ constructor(init: CodedErrorInit) {
28
+ super({ ...init, code: OtlpHeadersInvalidError.code });
29
+ }
30
+ }
31
+
18
32
  export class OtlpProtocolUnsupportedError extends UltimateError {
19
33
  static readonly code = 'X_OTLP_PROTOCOL_UNSUPPORTED';
20
34
  override readonly name = 'OtlpProtocolUnsupportedError';
@@ -112,6 +126,25 @@ export function otlpEndpoint(
112
126
  });
113
127
  }
114
128
 
129
+ /**
130
+ * One header value, percent-decoded. `decodeURIComponent` throws a bare `URIError` on a malformed
131
+ * escape (`%zz`, a lone `%`), and the whole of `otlpHeaders` runs at exporter construction — so a
132
+ * typo in an operator-set variable took the process down with an error carrying no code, no cause
133
+ * and no fix. Refused instead, naming the variable and the header KEY: the value is the
134
+ * collector's credential and a `cause:` is folded into a log line.
135
+ */
136
+ function decodeHeaderValue(key: string, raw: string): string {
137
+ try {
138
+ return decodeURIComponent(raw);
139
+ } catch {
140
+ throw new OtlpHeadersInvalidError({
141
+ cause: `${OTLP_HEADERS_KEY} carries a malformed percent-escape in the "${key}" value, so the header cannot be decoded`,
142
+ fix: `set ${OTLP_HEADERS_KEY}=${key}=<encoded>, where <encoded> is what bun -e 'console.log(encodeURIComponent(process.argv[1]))' <value> prints — or drop the stray % from the "${key}" value if it was meant literally`,
143
+ meta: { header: key },
144
+ });
145
+ }
146
+ }
147
+
115
148
  /** `key=value,key2=value2`, percent-decoded — the spec's format for collector auth headers. */
116
149
  export function otlpHeaders(
117
150
  explicit?: Readonly<Record<string, string>> | undefined,
@@ -125,7 +158,7 @@ export function otlpHeaders(
125
158
  if (index <= 0) continue;
126
159
  const key = pair.slice(0, index).trim().toLowerCase();
127
160
  if (key === '') continue;
128
- headers[key] = decodeURIComponent(pair.slice(index + 1).trim());
161
+ headers[key] = decodeHeaderValue(key, pair.slice(index + 1).trim());
129
162
  }
130
163
  }
131
164
  for (const [key, value] of Object.entries(explicit ?? {})) headers[key.toLowerCase()] = value;
package/src/registrar.ts CHANGED
@@ -27,6 +27,44 @@ export const PRIMITIVE_KINDS = [
27
27
 
28
28
  export type PrimitiveKind = (typeof PRIMITIVE_KINDS)[number];
29
29
 
30
+ /** One factory over one primitive: the export's name, the package that ships it, what it returns. */
31
+ export interface PrimitiveFactory {
32
+ readonly factory: string;
33
+ /** The package specifier the factory is imported from, so a `fix:` can be pasted. */
34
+ readonly pkg: string;
35
+ readonly kind: PrimitiveKind;
36
+ }
37
+
38
+ /**
39
+ * The other half of "never invent a ninth": the factories that already exist, in one table.
40
+ *
41
+ * Prose counted them — "the fourth instance of the framework's factory rule" — in three files that
42
+ * cannot see each other, so every ordinal was wrong the moment a fifth landed and none of them
43
+ * could be checked. A list here can be: `@ultimat3/cli` is tier 5, may import `ai`, `jobs` and
44
+ * `scraping`, and pins that every exported function returning an `Action`/`JobHandle` from outside
45
+ * their owning packages has a row. Adding a factory means adding a row, not editing a sentence.
46
+ *
47
+ * Sorted by package then name so the diff of a new row is one line.
48
+ *
49
+ * Every ROW is frozen, not just the list. `readonly` fields are a compile-time claim and this is a
50
+ * public export: freezing the array alone left `PRIMITIVE_FACTORIES[0].kind = 'entity'` a silent
51
+ * write from any untyped caller, which is the same defect `@ultimat3/money`'s currency rows
52
+ * carried — a table the framework hands out is a constant at RUNTIME or it is not a constant.
53
+ */
54
+ export const PRIMITIVE_FACTORIES = Object.freeze<readonly PrimitiveFactory[]>(
55
+ (
56
+ [
57
+ { factory: 'agent', pkg: '@ultimat3/ai', kind: 'action' },
58
+ { factory: 'agentJob', pkg: '@ultimat3/ai', kind: 'job' },
59
+ { factory: 'hive', pkg: '@ultimat3/ai', kind: 'action' },
60
+ { factory: 'llm', pkg: '@ultimat3/ai', kind: 'action' },
61
+ { factory: 'backfill', pkg: '@ultimat3/jobs', kind: 'job' },
62
+ { factory: 'purge', pkg: '@ultimat3/jobs', kind: 'job' },
63
+ { factory: 'scrape', pkg: '@ultimat3/scraping', kind: 'job' },
64
+ ] satisfies readonly PrimitiveFactory[]
65
+ ).map((entry) => Object.freeze(entry)),
66
+ );
67
+
30
68
  /**
31
69
  * What a registrar hands back: the primitives it actually took, each carrying the name
32
70
  * registration stamped on it. Returning the registered set — rather than nothing — is what lets
package/src/telemetry.ts CHANGED
@@ -167,7 +167,13 @@ export function currentSpan(): Span | undefined {
167
167
  return activeSpan.get();
168
168
  }
169
169
 
170
- /** The trace the caller is inside: active span, else the request context, else a fresh trace. */
170
+ /**
171
+ * The trace the caller is inside: active span, else the request context, else a fresh trace.
172
+ *
173
+ * The context branch carries an EMPTY `spanId` on purpose, and that emptiness is the discriminator
174
+ * every reader must honour: it is a trace id this process minted, not a span an upstream reported.
175
+ * `traceFlags: 1` here is the header this process would send onward, never a decision it received.
176
+ */
171
177
  export function currentSpanContext(): SpanContext | undefined {
172
178
  const span = activeSpan.get();
173
179
  if (span !== undefined) return span.context;
@@ -176,8 +182,20 @@ export function currentSpanContext(): SpanContext | undefined {
176
182
  return { traceId: ctx.traceId, spanId: '', traceFlags: 1 };
177
183
  }
178
184
 
185
+ /**
186
+ * A parent an upstream actually reported, as opposed to the synthetic one `currentSpanContext()`
187
+ * builds from a request context. Only the first carries a sampling decision: reading the synthetic
188
+ * one as inbound made `parentBasedRatioSampler` inherit a bit nobody sent, so every HTTP root span
189
+ * was exported at every ratio — `pipeline.ts` is `runWithContext` then `withSpan`, which is that
190
+ * exact pair — and the one lever between "tracing is on" and "the collector melts" did nothing.
191
+ */
192
+ function inboundParent(parent: SpanContext | undefined): SpanContext | undefined {
193
+ return parent === undefined || parent.spanId === '' ? undefined : parent;
194
+ }
195
+
179
196
  export function startSpan(name: string, options?: StartSpanOptions): Span {
180
197
  const parent = options?.parent ?? currentSpanContext();
198
+ const inbound = inboundParent(parent);
181
199
  const attributes: Record<string, AttributeValue> = { ...(options?.attributes ?? {}) };
182
200
  // The bit is decided ONCE, here, and every child of this span inherits it through `parent` —
183
201
  // so one trace is sampled or not sampled as a whole. Before this, `traceFlags` was hardcoded to
@@ -186,7 +204,7 @@ export function startSpan(name: string, options?: StartSpanOptions): Span {
186
204
  const context: SpanContext = {
187
205
  traceId: parent?.traceId ?? newTraceId(),
188
206
  spanId: newSpanId(),
189
- traceFlags: currentSampler().shouldSample(name, parent, attributes) ? 1 : 0,
207
+ traceFlags: currentSampler().shouldSample(name, inbound, attributes) ? 1 : 0,
190
208
  };
191
209
  const events: SpanEvent[] = [];
192
210
  const startedAt = clock.now().getTime();
@@ -245,7 +263,7 @@ export function startSpan(name: string, options?: StartSpanOptions): Span {
245
263
  // `traceparent`; it is simply not exported.
246
264
  if ((context.traceFlags & 1) === 0) return;
247
265
  const endedAt = clock.now().getTime();
248
- const parentSpanId = parent === undefined || parent.spanId === '' ? undefined : parent.spanId;
266
+ const parentSpanId = inbound?.spanId;
249
267
  exporter.export({
250
268
  name,
251
269
  kind: options?.kind ?? 'internal',
package/src/type-pins.ts CHANGED
@@ -1,10 +1,13 @@
1
- // Compile-time pins for the actor-facts seam, the config surface and the route vocabulary.
1
+ // Compile-time pins for the actor-facts seam, the config surface, the request-context patch and
2
+ // the route and cache-tier vocabularies.
2
3
  // Source, not a `.test.ts`, on purpose: `tsconfig.json` excludes `src/**/*.test.ts`, so `tsc -b`
3
4
  // never reads a test file and a type-level assertion written there can never fail. This module
4
5
  // emits nothing and exports nothing anybody imports — a regression is a build error.
5
6
 
6
7
  import type { Actor, ActorFactMap, FactKeysOf, FactMapOf } from './actor';
7
- import type { AppConfigInput, DatabaseConfig } from './config';
8
+ import type { CacheTierName } from './cache-vocabulary';
9
+ import type { AppConfigInput, CacheConfig, DatabaseConfig } from './config';
10
+ import type { CtxPatch } from './context';
8
11
  import type { HydrateStrategy, OfflineStrategy, RenderMode } from './route-vocabulary';
9
12
 
10
13
  /** Fails to compile when `T` is anything but `true`. The whole mechanism. */
@@ -99,6 +102,40 @@ type _DatabaseInputCarriesNoDeadField = Assert<
99
102
  : false
100
103
  >;
101
104
 
105
+ /**
106
+ * The two `config.cache` fields deleted 2026-08-22, held down for the reason the `database` three
107
+ * are: `cache.tiers` is what BUILDS the ladder, so `driver: 'redis'` was a second selector that
108
+ * selected nothing and `urlEnv` named an env key the Redis tier never reads — it reads the literal
109
+ * `REDIS_URL`. Re-adding either restores a knob an SRE sets, redeploys, and sees no effect from.
110
+ */
111
+ type DeadCacheField = 'driver' | 'urlEnv';
112
+
113
+ type _CacheConfigCarriesNoDeadField = Assert<
114
+ Extract<keyof CacheConfig, DeadCacheField> extends never ? true : false
115
+ >;
116
+
117
+ /** And the input side with it — `Input<CacheConfig>` is what an `app.config.ts` writes. */
118
+ type _CacheInputCarriesNoDeadField = Assert<
119
+ Extract<keyof NonNullable<AppConfigInput['cache']>, DeadCacheField> extends never ? true : false
120
+ >;
121
+
122
+ /**
123
+ * Neither id a child context may patch. `withChildContext` forwards the parent's `buildId`
124
+ * verbatim, so `{ buildId }` on the patch was an option that read as honoured and was dropped
125
+ * without a word — the same silent-no-op class as the three `database` fields above, one tier
126
+ * lower. `requestId` is here beside it because the two are refused for the same reason.
127
+ */
128
+ type UnpatchableCtxKey = 'requestId' | 'buildId';
129
+
130
+ type _CtxPatchRefusesTheIds = Assert<
131
+ Extract<keyof CtxPatch, UnpatchableCtxKey> extends never ? true : false
132
+ >;
133
+
134
+ /** And the keys a child MAY change are still there — a pin that empties the type is not a pin. */
135
+ type _CtxPatchStillPatchesTheRest = Assert<
136
+ 'actor' | 'locale' | 'tz' extends keyof CtxPatch ? true : false
137
+ >;
138
+
102
139
  /**
103
140
  * Mutual assignability, not one-way. The tuples are load-bearing: a bare `A extends B` distributes
104
141
  * over a union and answers `true` for every member separately, so it cannot see a widening — which
@@ -123,3 +160,33 @@ type _OfflineStrategyIsItsArray = Assert<
123
160
  type _HydrateStrategyIsItsArray = Assert<
124
161
  Exact<HydrateStrategy, 'idle' | 'visible' | 'interaction' | 'never'>
125
162
  >;
163
+
164
+ /**
165
+ * The cache ladder's rungs, same rule as the three above and for a defect that shipped: 8.0.0's
166
+ * `CacheTier` was a hand-written union in `config.ts` — `memo | lru | shared | isr | cdn` — while
167
+ * `@ultimat3/cache` ordered `request-memo | lru | redis | cdn`, so `cache: { tiers: ['isr'] }`
168
+ * typechecked and selected nothing (issue #293). Derived from `CACHE_TIERS` now, and pinned here
169
+ * because a `@ts-expect-error` in an excluded test file asserts nothing.
170
+ */
171
+ type _CacheTierNameIsItsArray = Assert<
172
+ Exact<CacheTierName, 'request-memo' | 'lru' | 'redis' | 'cdn'>
173
+ >;
174
+
175
+ /**
176
+ * The three spellings deleted in 9.0.0 must stay deleted. `memo` and `shared` were the ladder's
177
+ * near and shared rungs under a second name; `isr` was never a tier at all — it is a `RenderMode`,
178
+ * and re-admitting it would put a rung `sortTiers` places at `-1` (AHEAD of the request memo)
179
+ * back within reach of `app.config.ts`.
180
+ */
181
+ type DeadCacheTier = 'memo' | 'shared' | 'isr';
182
+
183
+ type _CacheTiersRefuseTheOldSpellings = Assert<
184
+ Extract<CacheTierName, DeadCacheTier> extends never ? true : false
185
+ >;
186
+
187
+ /** And `cache.tiers` is that vocabulary, not a second one — the whole of the fix. */
188
+ type _CacheConfigNamesTheLadder = Assert<Exact<CacheConfig['tiers'], readonly CacheTierName[]>>;
189
+
190
+ type _CacheInputNamesTheLadder = Assert<
191
+ Exact<NonNullable<AppConfigInput['cache']>['tiers'], readonly CacheTierName[] | undefined>
192
+ >;