@ultimat3/core 20.2.1 → 22.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/CLAUDE.md +189 -523
  2. package/README.md +62 -1
  3. package/package.json +6 -3
  4. package/src/actor.ts +14 -0
  5. package/src/address-class.ts +143 -0
  6. package/src/async-state.ts +19 -0
  7. package/src/canonical-json.ts +24 -1
  8. package/src/client-dispatch.ts +163 -0
  9. package/src/client-flight.ts +22 -3
  10. package/src/client-paths.ts +79 -0
  11. package/src/client-problem.ts +94 -0
  12. package/src/client-scope-error.ts +18 -0
  13. package/src/client-scope.ts +59 -0
  14. package/src/client-transport.ts +86 -0
  15. package/src/config-count.ts +19 -0
  16. package/src/config-fixes.ts +23 -0
  17. package/src/config-merge.ts +36 -0
  18. package/src/config.ts +122 -65
  19. package/src/conflict-policy.ts +48 -0
  20. package/src/context.ts +12 -1
  21. package/src/core-error-codes.ts +78 -0
  22. package/src/dev-secrets.ts +45 -0
  23. package/src/error-codes.ts +17 -66
  24. package/src/error-retry.ts +3 -0
  25. package/src/exports/error-contract.ts +2 -2
  26. package/src/exports/secrets.ts +1 -0
  27. package/src/generation-fence.ts +9 -2
  28. package/src/host-rules.ts +71 -0
  29. package/src/image/exif-orientation.ts +40 -0
  30. package/src/image/probe.ts +13 -1
  31. package/src/in-process-fetch.ts +39 -0
  32. package/src/index.ts +80 -30
  33. package/src/iso-date.ts +5 -0
  34. package/src/lifecycle-grace.ts +44 -0
  35. package/src/lifecycle-signals.ts +35 -0
  36. package/src/lifecycle.ts +44 -36
  37. package/src/logger.ts +22 -3
  38. package/src/measurement-actor.ts +52 -0
  39. package/src/metrics-text.ts +10 -2
  40. package/src/otlp-metric-exporter.ts +39 -12
  41. package/src/otlp-span-exporter.ts +38 -15
  42. package/src/outbound-headers.ts +16 -0
  43. package/src/outbox-drain.ts +14 -0
  44. package/src/page-meta.ts +41 -0
  45. package/src/page.ts +52 -0
  46. package/src/pending-records.ts +58 -0
  47. package/src/record-envelope-openapi.ts +41 -0
  48. package/src/record-envelope.ts +98 -0
  49. package/src/record-sink.ts +129 -0
  50. package/src/schema-error-codes.ts +1 -1
  51. package/src/secrets-errors.ts +1 -1
  52. package/src/secrets-store.ts +51 -5
  53. package/src/service.ts +9 -0
  54. package/src/source-mask.ts +30 -0
  55. package/src/telemetry.ts +3 -0
  56. package/src/type-pins.ts +9 -0
  57. package/src/write-digest.ts +29 -0
  58. package/src/write-origin.ts +31 -0
  59. package/src/result.ts +0 -78
package/src/config.ts CHANGED
@@ -7,15 +7,24 @@ import { CURRENCY_CODE_PATTERN } from '@ultimat3/schema';
7
7
  // them. Declaring them here is what let `cache.tiers` and the ladder `@ultimat3/cache` orders by
8
8
  // drift into two vocabularies with no map between them (issue #293).
9
9
  import { CACHE_TIERS, type CacheTierName } from './cache-vocabulary';
10
+ import { countIssue } from './config-count';
11
+ import { BASE_FIX, CACHE_TIER_FIX, TIMEZONE_FIX } from './config-fixes';
12
+ import { type Input, lastSaid, layered } from './config-merge';
10
13
  import type { PwaConfig, PwaOfflineConfig } from './config-pwa';
11
14
  import { PWA_FIX, pwaIssues } from './config-pwa';
12
15
  import { describeValue } from './error-render';
13
16
  import { ConfigInvalidError } from './errors';
17
+ import { defaultReadinessGraceMs, readinessGraceIssue } from './lifecycle-grace';
14
18
  import { ROLES, type Role } from './roles';
15
19
  import { isIanaZoneName } from './time-zone-name';
16
20
 
17
21
  export type ThemeMode = 'light' | 'dark' | 'system';
18
- export type RealtimeTransport = 'memory' | 'nats' | 'redis';
22
+ /**
23
+ * The buses `@ultimat3/realtime`'s `selectTransport` builds, and nothing else. `'redis'` was in
24
+ * this union until 22.0.0 with no Redis transport anywhere: it booted whatever `NATS_URL` chose.
25
+ */
26
+ export const REALTIME_TRANSPORTS = ['memory', 'nats'] as const;
27
+ export type RealtimeTransport = (typeof REALTIME_TRANSPORTS)[number];
19
28
 
20
29
  export interface ThemeConfig {
21
30
  readonly defaultMode: ThemeMode;
@@ -172,6 +181,20 @@ export interface AiConfig {
172
181
  readonly mcp: McpConfig;
173
182
  }
174
183
 
184
+ /**
185
+ * How a SIGTERM'd process leaves the load balancer. Read by `@ultimat3/http`'s `createServer`
186
+ * (`ServerOptions.drain`), which hands it to core's `configureLifecycle`.
187
+ */
188
+ export interface DrainConfig {
189
+ /**
190
+ * `/readyz` answers 503 for this long before the listener closes, so endpoints stop routing here
191
+ * first. Default 0 in development/test and 5000 everywhere else — a process naming NO environment
192
+ * included. A whole number, 0–60000. The chart's `terminationGracePeriodSeconds` must exceed it
193
+ * plus the drain budget.
194
+ */
195
+ readonly readinessGraceMs: number;
196
+ }
197
+
175
198
  export interface AppConfig {
176
199
  readonly name: string;
177
200
  readonly locales: readonly string[];
@@ -188,10 +211,9 @@ export interface AppConfig {
188
211
  readonly realtime: RealtimeConfig;
189
212
  readonly notify: NotifyConfig;
190
213
  readonly ai: AiConfig;
214
+ readonly drain: DrainConfig;
191
215
  }
192
216
 
193
- type Input<T> = { readonly [K in keyof T]?: T[K] | undefined };
194
-
195
217
  /** `mcp` is the only member, and it is NESTED — `Input<AiConfig>` would make it all-or-nothing. */
196
218
  export interface AiConfigInput {
197
219
  readonly mcp?: Input<McpConfig> | undefined;
@@ -224,24 +246,12 @@ export interface AppConfigInput {
224
246
  readonly realtime?: Input<RealtimeConfig> | undefined;
225
247
  readonly notify?: Input<NotifyConfig> | undefined;
226
248
  readonly ai?: AiConfigInput | undefined;
249
+ readonly drain?: Input<DrainConfig> | undefined;
227
250
  }
228
251
 
229
252
  /** An overlay from `config/<concern>.ts`. No `name` — the base owns it. */
230
253
  export type AppConfigOverlay = Omit<AppConfigInput, 'name'> & { readonly name?: string };
231
254
 
232
- /**
233
- * Apply a partial section over its defaults. Explicit `undefined` never wins — that is what
234
- * makes every config field deeply optional without `exactOptionalPropertyTypes` fighting back.
235
- */
236
- function section<T extends object>(base: T, patch: Input<T> | undefined): T {
237
- if (patch === undefined) return base;
238
- const out: Record<string, unknown> = { ...(base as Record<string, unknown>) };
239
- for (const [key, value] of Object.entries(patch)) {
240
- if (value !== undefined) out[key] = value;
241
- }
242
- return out as T;
243
- }
244
-
245
255
  const NAME_RE = /^[a-z][a-z0-9-]{1,63}$/;
246
256
 
247
257
  /**
@@ -287,32 +297,16 @@ function defaults(name: string): Omit<AppConfig, 'name'> {
287
297
  backoff: 'exponential',
288
298
  visibilityTimeoutMs: 30_000,
289
299
  },
290
- realtime: { enabled: false, transport: 'memory', urlEnv: undefined },
300
+ // ON by default since 22.0.0, when the boot began obeying the key: an app with no section
301
+ // keeps the `sync` node it always got, and `enabled: false` is the explicit opt-out.
302
+ realtime: { enabled: true, transport: 'memory', urlEnv: undefined },
291
303
  notify: { inboxReadRetentionMs: undefined, inboxUnreadRetentionMs: undefined },
292
304
  ai: { mcp: { expose: true, path: '/mcp' } },
305
+ // Read from the process env when the config is DEFINED — the same env the drain will run in.
306
+ drain: { readinessGraceMs: defaultReadinessGraceMs() },
293
307
  };
294
308
  }
295
309
 
296
- const BASE_FIX = 'edit app.config.ts to fix the fields named in cause, then run: x verify';
297
-
298
- /**
299
- * Appended only when the zone is what failed. Axiom 4: an operator holding `'CET'` needs the
300
- * spelling to write, and the two refused classes have different remedies — a single-label legacy
301
- * name swaps mechanically, an abbreviation or an offset has no replacement at all because it names
302
- * no jurisdiction. Deliberately parallel to `@ultimat3/time`'s `X_TIMEZONE_INVALID` fix, since the
303
- * two refuse the same strings and an operator may meet either first.
304
- */
305
- const TIMEZONE_FIX =
306
- "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)";
307
-
308
- /**
309
- * Appended only when a tier name is what failed, and it names the rename rather than the rule: the
310
- * three refused spellings are the ones 8.0.0 accepted, and two of them have a mechanical
311
- * replacement while `isr` has none — it is a `RenderMode`, and no cache tier ever served it.
312
- */
313
- const CACHE_TIER_FIX =
314
- "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";
315
-
316
310
  function validate(config: AppConfig): void {
317
311
  const issues: string[] = [];
318
312
  // Zero or one entry: the zone's own remedy, carried only when the zone is what failed.
@@ -346,10 +340,25 @@ function validate(config: AppConfig): void {
346
340
  issues.push(`defaultCurrency "${config.defaultCurrency}" is not a 3-letter ISO 4217 code`);
347
341
  }
348
342
  if (config.roles.length === 0) issues.push('roles must list at least one runtime role');
349
- if (config.jobs.concurrency < 1) issues.push('jobs.concurrency must be >= 1');
343
+ // A domain per numeric key, never a bare `< 1`: every comparison with `NaN` is false, so the old
344
+ // `concurrency < 1` passed `NaN`, `2.5` and `Infinity`, and nothing screened the other three.
345
+ const counts: readonly (string | undefined)[] = [
346
+ countIssue('jobs.concurrency', config.jobs.concurrency, 1),
347
+ countIssue('jobs.maxAttempts', config.jobs.maxAttempts, 1),
348
+ countIssue('jobs.visibilityTimeoutMs', config.jobs.visibilityTimeoutMs, 1),
349
+ countIssue('cache.defaultTtlMs', config.cache.defaultTtlMs, 0),
350
+ readinessGraceIssue(config.drain.readinessGraceMs),
351
+ ];
352
+ for (const issue of counts) if (issue !== undefined) issues.push(issue);
350
353
  if (config.jobs.queues.length === 0) issues.push('jobs.queues must list at least one queue');
351
- if (config.realtime.transport !== 'memory' && config.realtime.urlEnv === undefined) {
352
- issues.push(`realtime.transport "${config.realtime.transport}" requires realtime.urlEnv`);
354
+ // An untyped config reaches here with whatever it wrote: a string is a name worth echoing, and
355
+ // anything else goes through `describeValue` rather than `${…}`.
356
+ const transport: unknown = config.realtime.transport;
357
+ if (!REALTIME_TRANSPORTS.some((known) => known === transport)) {
358
+ const said = typeof transport === 'string' ? `"${transport}"` : describeValue(transport);
359
+ issues.push(`realtime.transport ${said} is not one of ${REALTIME_TRANSPORTS.join(', ')}`);
360
+ } else if (transport === 'nats' && config.realtime.urlEnv === undefined) {
361
+ issues.push(`realtime.transport "nats" requires realtime.urlEnv`);
353
362
  }
354
363
  // BOTH RETENTION WINDOWS OR NEITHER — `undefined` is a real value here (never swept) and the
355
364
  // only other legal one is a positive, finite count of milliseconds. Zero is refused rather than
@@ -401,35 +410,83 @@ export function defineConfig(
401
410
  ...overlays: readonly AppConfigOverlay[]
402
411
  ): AppConfig {
403
412
  const base = defaults(input.name);
404
- // One `Object.assign` over all overlays rather than a spread per overlay: `reduce` with a
405
- // spread copies every key again on each step, and config is merged at boot on every start.
406
- // `name` is applied last because it identifies the app — an overlay may not rename it.
407
- const merged: AppConfigInput = Object.assign({}, input, ...overlays, {
408
- name: input.name,
409
- }) as AppConfigInput;
413
+ // Every layer, in order, merged per section and KEY BY KEY — see `layered`. `name` comes from
414
+ // the input alone because it identifies the app: an overlay may not rename it.
415
+ const layers: readonly AppConfigOverlay[] = [input, ...overlays];
410
416
 
411
417
  const config: AppConfig = {
412
- name: merged.name,
413
- locales: merged.locales ?? base.locales,
414
- defaultLocale: merged.defaultLocale ?? base.defaultLocale,
415
- defaultTimeZone: merged.defaultTimeZone ?? base.defaultTimeZone,
416
- defaultCurrency: merged.defaultCurrency ?? base.defaultCurrency,
417
- theme: section(base.theme, merged.theme),
418
- auth: section(base.auth, merged.auth),
419
- // Two `section` calls, one per level: the outer one may not see `offline` at all, or it would
420
- // drop the nested defaults `PwaConfigInput` exists to keep. Hence the cast — the outer patch is
421
- // this block minus the key the inner call owns.
418
+ name: input.name,
419
+ locales: lastSaid(
420
+ base.locales,
421
+ layers.map((layer) => layer.locales),
422
+ ),
423
+ defaultLocale: lastSaid(
424
+ base.defaultLocale,
425
+ layers.map((layer) => layer.defaultLocale),
426
+ ),
427
+ defaultTimeZone: lastSaid(
428
+ base.defaultTimeZone,
429
+ layers.map((layer) => layer.defaultTimeZone),
430
+ ),
431
+ defaultCurrency: lastSaid(
432
+ base.defaultCurrency,
433
+ layers.map((layer) => layer.defaultCurrency),
434
+ ),
435
+ theme: layered(
436
+ base.theme,
437
+ layers.map((layer) => layer.theme),
438
+ ),
439
+ auth: layered(
440
+ base.auth,
441
+ layers.map((layer) => layer.auth),
442
+ ),
443
+ // Two merges, one per level: the outer one may not see `offline` at all, or it would drop the
444
+ // nested defaults `PwaConfigInput` exists to keep. Hence the cast — the outer patch is this
445
+ // block minus the key the inner merge owns.
422
446
  pwa: {
423
- ...section(base.pwa, { ...merged.pwa, offline: undefined } as Input<PwaConfig>),
424
- offline: section(base.pwa.offline, merged.pwa?.offline),
447
+ ...layered(
448
+ base.pwa,
449
+ layers.map((layer) => ({ ...layer.pwa, offline: undefined }) as Input<PwaConfig>),
450
+ ),
451
+ offline: layered(
452
+ base.pwa.offline,
453
+ layers.map((layer) => layer.pwa?.offline),
454
+ ),
455
+ },
456
+ roles: lastSaid(
457
+ base.roles,
458
+ layers.map((layer) => layer.roles),
459
+ ),
460
+ database: layered(
461
+ base.database,
462
+ layers.map((layer) => layer.database),
463
+ ),
464
+ cache: layered(
465
+ base.cache,
466
+ layers.map((layer) => layer.cache),
467
+ ),
468
+ jobs: layered(
469
+ base.jobs,
470
+ layers.map((layer) => layer.jobs),
471
+ ),
472
+ realtime: layered(
473
+ base.realtime,
474
+ layers.map((layer) => layer.realtime),
475
+ ),
476
+ notify: layered(
477
+ base.notify,
478
+ layers.map((layer) => layer.notify),
479
+ ),
480
+ ai: {
481
+ mcp: layered(
482
+ base.ai.mcp,
483
+ layers.map((layer) => layer.ai?.mcp),
484
+ ),
425
485
  },
426
- roles: merged.roles ?? base.roles,
427
- database: section(base.database, merged.database),
428
- cache: section(base.cache, merged.cache),
429
- jobs: section(base.jobs, merged.jobs),
430
- realtime: section(base.realtime, merged.realtime),
431
- notify: section(base.notify, merged.notify),
432
- ai: { mcp: section(base.ai.mcp, merged.ai?.mcp) },
486
+ drain: layered(
487
+ base.drain,
488
+ layers.map((layer) => layer.drain),
489
+ ),
433
490
  };
434
491
 
435
492
  validate(config);
@@ -0,0 +1,48 @@
1
+ /**
2
+ * The ONE conflict vocabulary: which row survives when an optimistic local write and the server's
3
+ * answer disagree. Row-shaped because the client store is — a merge over a mutator's OUTPUT had
4
+ * nowhere to land, and realtime's rebase silently dropped it. Tier 0 so `action` and `realtime`
5
+ * (both tier 3) name the same type.
6
+ */
7
+
8
+ /** One record as the client store holds it: a JSON object, keyed by field name. */
9
+ export type Row = Readonly<Record<string, unknown>>;
10
+
11
+ export type ConflictPolicy =
12
+ | 'server-wins'
13
+ | 'last-write-wins'
14
+ | { readonly kind: 'custom'; readonly merge: (local: Row, server: Row) => Row };
15
+
16
+ export interface ResolveConflictOptions {
17
+ /**
18
+ * The field `last-write-wins` compares — a finite number (epoch ms) the SERVER wrote. Default
19
+ * `updatedAt`, the name realtime's rebase has always read.
20
+ */
21
+ readonly clockField?: string | undefined;
22
+ }
23
+
24
+ /**
25
+ * The surviving row. `last-write-wins` keeps the local row only when its clock is provably newer
26
+ * by the server's own field; a missing or non-numeric clock on either side is no proof, so the
27
+ * server's row stands — the store must never keep a guess over an answer.
28
+ */
29
+ export function resolveConflict(
30
+ policy: ConflictPolicy,
31
+ local: Row,
32
+ server: Row,
33
+ options: ResolveConflictOptions = {},
34
+ ): Row {
35
+ if (typeof policy !== 'string') return policy.merge(local, server);
36
+ if (policy === 'server-wins') return server;
37
+ const field = options.clockField ?? 'updatedAt';
38
+ const localAt = clockOf(local, field);
39
+ const serverAt = clockOf(server, field);
40
+ return localAt !== undefined && serverAt !== undefined && localAt > serverAt ? local : server;
41
+ }
42
+
43
+ function clockOf(row: Row, field: string): number | undefined {
44
+ // Own keys only: a field named `constructor` must not read `Object.prototype`'s.
45
+ if (!Object.hasOwn(row, field)) return undefined;
46
+ const value = row[field];
47
+ return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
48
+ }
package/src/context.ts CHANGED
@@ -37,6 +37,7 @@ import { UltimateError } from './errors';
37
37
  import { finiteOption } from './finite-option';
38
38
  import { traceId as newTraceId, uuid } from './ids';
39
39
  import { type Logger, logger as rootLogger, setLoggerContextFields } from './logger';
40
+ import { installTraceHeaders } from './outbound-headers';
40
41
  import { type Role, resolveRole } from './roles';
41
42
  import { installedServices, isManagedService } from './service';
42
43
 
@@ -130,6 +131,13 @@ export interface CtxInit {
130
131
  /** Epoch ms. `@ultimat3/http`'s `startDeadline` is the one production writer. */
131
132
  readonly deadlineAt?: number | undefined;
132
133
  readonly services?: ServiceBag | undefined;
134
+ /**
135
+ * `false` installs no `defineService` factory — only `services` — and leaves the registered
136
+ * ones to the caller. `@ultimat3/http` is that caller: its context exists before the `auth`
137
+ * stage names the actor, and a service built here would act as anonymous for the whole request.
138
+ * Default `true`.
139
+ */
140
+ readonly installServices?: boolean | undefined;
133
141
  }
134
142
 
135
143
  /**
@@ -184,7 +192,8 @@ export function createContext(init: CtxInit = {}): Ctx {
184
192
  // wins over an auto-installed one of the same name — a test's hand-built mock overrides the
185
193
  // real thing on purpose.
186
194
  const preview: CtxFacts = Object.freeze({ ...explicit, ...fields, services: explicit });
187
- const services: ServiceBag = Object.freeze({ ...installedServices(preview), ...explicit });
195
+ const installed = init.installServices === false ? {} : installedServices(preview);
196
+ const services: ServiceBag = Object.freeze({ ...installed, ...explicit });
188
197
  const ctx = {
189
198
  // Services ride ON the context, not only under `ctx.services`: `CtxServices` exists to be
190
199
  // augmented, so `ctx.posts` has to BE the service. Spread first, so a service that collides
@@ -208,6 +217,8 @@ export function createContext(init: CtxInit = {}): Ctx {
208
217
  }
209
218
 
210
219
  export function runWithContext<T>(ctx: Ctx, fn: () => T): T {
220
+ // A request scope is what gives an outbound typed call a budget to forward; see the module.
221
+ installTraceHeaders();
211
222
  return requestContext.run(ctx, fn);
212
223
  }
213
224
 
@@ -0,0 +1,78 @@
1
+ // Single responsibility: core's OWN error-code titles, registered at import. A side-effect
2
+ // anchor, bare-imported by the barrel — the same shape as `schema-error-codes.ts` — so the table
3
+ // rides every barrel import and none of the light paths: `UltimateError` alone no longer carries
4
+ // 40 titles into a browser island that throws one code. Listed in `SIDE_EFFECTS_ANCHORS`.
5
+
6
+ import type { ErrorCodeDescriptor } from './error-codes';
7
+ import { descriptor, registerCoreErrorCodes } from './error-codes';
8
+
9
+ /** Codes owned by `@ultimat3/core`. Every other package calls `registerErrorCodes()`. */
10
+ const CORE_CODE_TITLES = {
11
+ X_ABORTED: 'operation aborted',
12
+ X_ASYNC_CONTEXT_UNAVAILABLE: 'async context unavailable',
13
+ // The browser seam's three (`client-transport.ts`, `record-envelope.ts`, `client-scope.ts`).
14
+ X_CLIENT_RECORD_ENVELOPE_INVALID: 'a response marked as a records envelope has the wrong shape',
15
+ X_CLIENT_SCOPE_CHANGED: 'the page changed principal while this request was in flight',
16
+ X_CLIENT_TRANSPORT_FAILED: 'a browser request got no answer from the app',
17
+ X_CONFIG_INVALID: 'app.config.ts is invalid',
18
+ X_CURSOR_INVALID: 'pagination cursor is malformed, tampered with or from another query',
19
+ // `x doctor` reports it; `assertNoDevSecretsOutsideLocal()` throws it at boot.
20
+ X_CURSOR_SECRET_DEV: 'cursors are signed with the shipped development key',
21
+ X_DRAINING: 'process is draining and refuses new work',
22
+ X_ENV_EXAMPLE_DRIFT: '.env.example does not declare every variable the schema requires',
23
+ X_ENV_MISSING: 'required environment variables are missing or invalid',
24
+ X_ENVIRONMENT_INVALID: 'ULTIMATE_ENV is not a known environment',
25
+ X_ERROR_CODE_DUPLICATE: 'error code registered twice',
26
+ X_ERROR_REPORTER_DSN_INVALID: 'the error monitor DSN is malformed',
27
+ X_ERROR_RETRY_INVALID: 'error retry classification is unknown or already claimed',
28
+ // Core's own, and deliberately NOT `@ultimat3/http`'s `X_OVERLOADED`: that code is owned by a
29
+ // tier-2 package, and a tier-0 gate borrowing it upward is an import core may not make. The two
30
+ // read alike to an operator and the fix lines say which ceiling to widen.
31
+ X_FLIGHT_GATE_OVERLOADED: 'a concurrency gate is at its ceiling and its queue is full',
32
+ X_ID_INVALID: 'value is not a valid id',
33
+ X_IMAGE_DECODE_FAILED: 'image bytes are malformed, truncated or internally inconsistent',
34
+ X_IMAGE_TOO_LARGE: 'image exceeds the pipeline pixel ceiling',
35
+ X_IMAGE_UNSUPPORTED: 'the built-in image pipeline cannot read or write this format',
36
+ X_INTERNAL: 'unexpected internal framework error',
37
+ X_INVARIANT: 'invariant violated',
38
+ // Owned here rather than by `@ultimat3/time`, which declared it until 16.x: `@ultimat3/money`
39
+ // needs the same screen and tier 1 may not import sideways. One code, one declaration.
40
+ X_LOCALE_INVALID: 'not a well-formed BCP 47 tag',
41
+ X_METRIC_CARDINALITY:
42
+ 'a metric exceeded its series ceiling and is folding into one overflow series',
43
+ X_METRIC_NAME_INVALID:
44
+ 'metric name is malformed, or redeclared with a different kind, bounds or observer',
45
+ X_METRIC_VALUE_INVALID: 'metric value is not recordable',
46
+ X_NO_CONTEXT: 'no request context is active',
47
+ X_NOT_IMPLEMENTED: 'this driver does not implement the requested feature',
48
+ X_OTLP_ENDPOINT_INVALID: 'the OTLP collector endpoint is missing or malformed',
49
+ // Its own code rather than the endpoint's, because a title is what an agent reads first:
50
+ // `x errors explain X_OTLP_ENDPOINT_INVALID` would send it to inspect a variable that is fine.
51
+ X_OTLP_HEADERS_INVALID: 'OTEL_EXPORTER_OTLP_HEADERS is malformed',
52
+ X_OTLP_PROTOCOL_UNSUPPORTED: 'the OTLP protocol requested is not OTLP/HTTP JSON',
53
+ X_READINESS_CHECK_DUPLICATE: 'a readiness check name is registered twice',
54
+ X_REGISTRAR_CONFLICT: 'two different registrars are loaded for one primitive kind',
55
+ X_REGISTRAR_MISSING: 'no registrar is loaded for a primitive kind',
56
+ X_ROLE_INVALID: 'ROLE is not a known runtime role',
57
+ X_SERVICE_DUPLICATE: 'a service name is registered twice',
58
+ X_SERVICE_MISSING: 'service is not registered on the request context',
59
+ X_SHUTDOWN_TIMEOUT: 'graceful shutdown exceeded its deadline',
60
+ X_SUPERSEDED: 'a later generation superseded this work',
61
+ X_TELEMETRY_SAMPLER_ARG_INVALID: 'the trace sampling ratio is not a number between 0 and 1',
62
+ // Core's, though core does not throw it — the twin of `X_ABORTED`, and `@ultimat3/http` already
63
+ // calls it "borrowed (core's concept)" in `HTTP_BORROWED_ERROR_CODES`. A deadline that expired
64
+ // and a caller that went away are one pair of facts, so they are titled and classified in one
65
+ // place rather than by whichever package happened to raise one first.
66
+ X_TIMEOUT: 'operation exceeded its deadline',
67
+ X_UNREACHABLE: 'unreachable branch was reached',
68
+ } as const;
69
+
70
+ export type CoreErrorCode = keyof typeof CORE_CODE_TITLES;
71
+
72
+ export const CORE_ERROR_CODES: Readonly<Record<CoreErrorCode, ErrorCodeDescriptor>> = Object.freeze(
73
+ Object.fromEntries(
74
+ Object.entries(CORE_CODE_TITLES).map(([code, title]) => [code, descriptor({ title })]),
75
+ ) as Record<CoreErrorCode, ErrorCodeDescriptor>,
76
+ );
77
+
78
+ registerCoreErrorCodes(CORE_ERROR_CODES);
@@ -0,0 +1,45 @@
1
+ // Single responsibility: the boot refusal of a shipped development signing secret outside a local
2
+ // environment. `x doctor` reported it; nothing failed — so a production pod that forgot
3
+ // ULTIMATE_CURSOR_SECRET signed every cursor with a key published in this package.
4
+
5
+ import { usesDevCursorSecret } from './cursor';
6
+ import { isLocal } from './environment';
7
+ import { UltimateError } from './errors';
8
+
9
+ /**
10
+ * `X_CURSOR_SECRET_DEV`, the code `x doctor` already reports for this key — one condition, one code.
11
+ * Doctor warns; this refuses the boot.
12
+ */
13
+ export class CursorSecretDevError extends UltimateError {
14
+ static readonly code = 'X_CURSOR_SECRET_DEV';
15
+ override readonly name = 'CursorSecretDevError';
16
+
17
+ // One secret today, so the fix is a literal: a spliced name would be a value in a pasted command.
18
+ constructor() {
19
+ super({
20
+ code: CursorSecretDevError.code,
21
+ cause:
22
+ 'ULTIMATE_CURSOR_SECRET is unset, so this process signs cursors with the development key the framework ships — anyone can forge a page position',
23
+ fix: "x secrets set ULTIMATE_CURSOR_SECRET — or export ULTIMATE_CURSOR_SECRET from the platform's secret store",
24
+ meta: { variable: 'ULTIMATE_CURSOR_SECRET' },
25
+ });
26
+ }
27
+ }
28
+
29
+ export interface DevSecretsOptions {
30
+ /** Which environment the boot is — defaults to `process.env`. */
31
+ readonly env?: Readonly<Record<string, string | undefined>> | undefined;
32
+ }
33
+
34
+ /**
35
+ * Throws when a shipped dev secret is in use and the environment is not `development`/`test`.
36
+ *
37
+ * FAILS CLOSED: a process with neither `ULTIMATE_ENV` nor `NODE_ENV` resolves as `production`
38
+ * here, the answer `templates/scaffold-auth.ts` already gives — `isLocal()`'s own fallback is
39
+ * `development`, which is exactly the process that forgot to say. The secret itself is read where
40
+ * signing reads it, so this refuses what the process WILL sign with, not what `env` claims.
41
+ */
42
+ export function assertNoDevSecretsOutsideLocal(options: DevSecretsOptions = {}): void {
43
+ if (isLocal({ env: options.env, fallback: 'production' })) return;
44
+ if (usesDevCursorSecret()) throw new CursorSecretDevError();
45
+ }
@@ -28,75 +28,26 @@ export interface ErrorCodeEntry extends ErrorCodeDescriptor {
28
28
  */
29
29
  export const ERROR_DOCS_URL = 'https://github.com/developerz-ai/ultimate/wiki/Error-Codes';
30
30
 
31
- /** Codes owned by `@ultimat3/core`. Every other package calls `registerErrorCodes()`. */
32
- const CORE_CODE_TITLES = {
33
- X_ABORTED: 'operation aborted',
34
- X_ASYNC_CONTEXT_UNAVAILABLE: 'async context unavailable',
35
- X_CONFIG_INVALID: 'app.config.ts is invalid',
36
- X_CURSOR_INVALID: 'pagination cursor is malformed, tampered with or from another query',
37
- X_CURSOR_SECRET_DEV: 'cursors are signed with the shipped development key',
38
- X_DRAINING: 'process is draining and refuses new work',
39
- X_ENV_EXAMPLE_DRIFT: '.env.example does not declare every variable the schema requires',
40
- X_ENV_MISSING: 'required environment variables are missing or invalid',
41
- X_ENVIRONMENT_INVALID: 'ULTIMATE_ENV is not a known environment',
42
- X_ERROR_CODE_DUPLICATE: 'error code registered twice',
43
- X_ERROR_REPORTER_DSN_INVALID: 'the error monitor DSN is malformed',
44
- X_ERROR_RETRY_INVALID: 'error retry classification is unknown or already claimed',
45
- // Core's own, and deliberately NOT `@ultimat3/http`'s `X_OVERLOADED`: that code is owned by a
46
- // tier-2 package, and a tier-0 gate borrowing it upward is an import core may not make. The two
47
- // read alike to an operator and the fix lines say which ceiling to widen.
48
- X_FLIGHT_GATE_OVERLOADED: 'a concurrency gate is at its ceiling and its queue is full',
49
- X_ID_INVALID: 'value is not a valid id',
50
- X_IMAGE_DECODE_FAILED: 'image bytes are malformed, truncated or internally inconsistent',
51
- X_IMAGE_TOO_LARGE: 'image exceeds the pipeline pixel ceiling',
52
- X_IMAGE_UNSUPPORTED: 'the built-in image pipeline cannot read or write this format',
53
- X_INTERNAL: 'unexpected internal framework error',
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',
58
- X_METRIC_CARDINALITY:
59
- 'a metric exceeded its series ceiling and is folding into one overflow series',
60
- X_METRIC_NAME_INVALID:
61
- 'metric name is malformed, or redeclared with a different kind, bounds or observer',
62
- X_METRIC_VALUE_INVALID: 'metric value is not recordable',
63
- X_NO_CONTEXT: 'no request context is active',
64
- X_NOT_IMPLEMENTED: 'this driver does not implement the requested feature',
65
- X_OTLP_ENDPOINT_INVALID: 'the OTLP collector endpoint is missing or malformed',
66
- // Its own code rather than the endpoint's, because a title is what an agent reads first:
67
- // `x errors explain X_OTLP_ENDPOINT_INVALID` would send it to inspect a variable that is fine.
68
- X_OTLP_HEADERS_INVALID: 'OTEL_EXPORTER_OTLP_HEADERS is malformed',
69
- X_OTLP_PROTOCOL_UNSUPPORTED: 'the OTLP protocol requested is not OTLP/HTTP JSON',
70
- X_READINESS_CHECK_DUPLICATE: 'a readiness check name is registered twice',
71
- X_REGISTRAR_CONFLICT: 'two different registrars are loaded for one primitive kind',
72
- X_REGISTRAR_MISSING: 'no registrar is loaded for a primitive kind',
73
- X_ROLE_INVALID: 'ROLE is not a known runtime role',
74
- X_SERVICE_DUPLICATE: 'a service name is registered twice',
75
- X_SERVICE_MISSING: 'service is not registered on the request context',
76
- X_SHUTDOWN_TIMEOUT: 'graceful shutdown exceeded its deadline',
77
- X_SUPERSEDED: 'a later generation superseded this work',
78
- X_TELEMETRY_SAMPLER_ARG_INVALID: 'the trace sampling ratio is not a number between 0 and 1',
79
- // Core's, though core does not throw it — the twin of `X_ABORTED`, and `@ultimat3/http` already
80
- // calls it "borrowed (core's concept)" in `HTTP_BORROWED_ERROR_CODES`. A deadline that expired
81
- // and a caller that went away are one pair of facts, so they are titled and classified in one
82
- // place rather than by whichever package happened to raise one first.
83
- X_TIMEOUT: 'operation exceeded its deadline',
84
- X_UNREACHABLE: 'unreachable branch was reached',
85
- } as const;
86
-
87
- export type CoreErrorCode = keyof typeof CORE_CODE_TITLES;
88
-
89
- function descriptor(declaration: ErrorCodeDeclaration): ErrorCodeDescriptor {
31
+ export function descriptor(declaration: ErrorCodeDeclaration): ErrorCodeDescriptor {
90
32
  return Object.freeze({ title: declaration.title, docs: declaration.docs ?? ERROR_DOCS_URL });
91
33
  }
92
34
 
93
- export const CORE_ERROR_CODES: Readonly<Record<CoreErrorCode, ErrorCodeDescriptor>> = Object.freeze(
94
- Object.fromEntries(
95
- Object.entries(CORE_CODE_TITLES).map(([code, title]) => [code, descriptor({ title })]),
96
- ) as Record<CoreErrorCode, ErrorCodeDescriptor>,
97
- );
35
+ /**
36
+ * Empty at load, on purpose: core's own titles live in `core-error-codes.ts`, which the barrel
37
+ * bare-imports as a side-effect anchor. A browser module that constructs an `UltimateError` from a
38
+ * light path pays for the class and this lookup, not a 40-row table — an untitled code renders
39
+ * through `humanize`, the trade `@ultimat3/realtime` already made for its own titles.
40
+ */
41
+ const registry = new Map<string, ErrorCodeDescriptor>();
98
42
 
99
- const registry = new Map<string, ErrorCodeDescriptor>(Object.entries(CORE_ERROR_CODES));
43
+ /** What `resetErrorCodes` restores: the codes `registerCoreErrorCodes` installed. */
44
+ const coreCodes = new Map<string, ErrorCodeDescriptor>();
45
+
46
+ /** `core-error-codes.ts`'s one call. Registered like any package's, and remembered for a reset. */
47
+ export function registerCoreErrorCodes(codes: Readonly<Record<string, ErrorCodeDescriptor>>): void {
48
+ registerErrorCodes(codes);
49
+ for (const [code, value] of Object.entries(codes)) coreCodes.set(code, value);
50
+ }
100
51
 
101
52
  /**
102
53
  * Register a package's codes. Throws `X_ERROR_CODE_DUPLICATE` on collision so two packages
@@ -145,7 +96,7 @@ export function listErrorCodes(): readonly ErrorCodeEntry[] {
145
96
  /** Test-only: drop everything a package registered, keeping core's codes. */
146
97
  export function resetErrorCodes(): void {
147
98
  registry.clear();
148
- for (const [code, value] of Object.entries(CORE_ERROR_CODES)) registry.set(code, value);
99
+ for (const [code, value] of coreCodes) registry.set(code, value);
149
100
  }
150
101
 
151
102
  /**
@@ -58,6 +58,9 @@ const CORE_ERROR_RETRY: ReadonlyMap<string, ErrorRetry> = new Map(
58
58
  // re-run produces the same refusal by construction, so an UNCLASSIFIED reading would spend a
59
59
  // job's whole retry policy proving that the world has still moved on.
60
60
  X_SUPERSEDED: 'terminal',
61
+ // The principal fence's twin of `X_SUPERSEDED`, listed for the same reason: re-sending a read
62
+ // from the previous principal's scope is refused identically every time.
63
+ X_CLIENT_SCOPE_CHANGED: 'terminal',
61
64
  } as const),
62
65
  );
63
66
 
@@ -3,14 +3,14 @@
3
3
  // built with, and the retry classification a code carries. One group because a code, its title,
4
4
  // its rendering and its retry class are one contract; `index.ts` re-exports every name explicitly.
5
5
 
6
+ export type { CoreErrorCode } from '../core-error-codes';
7
+ export { CORE_ERROR_CODES } from '../core-error-codes';
6
8
  export type {
7
- CoreErrorCode,
8
9
  ErrorCodeDeclaration,
9
10
  ErrorCodeDescriptor,
10
11
  ErrorCodeEntry,
11
12
  } from '../error-codes';
12
13
  export {
13
- CORE_ERROR_CODES,
14
14
  describeErrorCode,
15
15
  ERROR_DOCS_URL,
16
16
  errorCodeSnapshot,
@@ -66,6 +66,7 @@ export {
66
66
  SECRETS_KEY_MODE,
67
67
  secretsFileExists,
68
68
  secretsPath,
69
+ stagedMasterKeyPath,
69
70
  writeMasterKeyFile,
70
71
  writeSecretsFile,
71
72
  } from '../secrets-store';
@@ -51,7 +51,14 @@ function superseded(subject: string, issued: number, current: number): UltimateE
51
51
  });
52
52
  }
53
53
 
54
- /** Whether a caught value is this refusal. The one reader a caller needs; never `error.code`. */
54
+ /**
55
+ * Whether a caught value is a supersession — this fence's refusal, or `X_CLIENT_SCOPE_CHANGED`,
56
+ * the principal fence's (`client-scope.ts`). One reader for both, because a caller does the same
57
+ * thing with either: drop the answer and render nothing. Never `error.code`.
58
+ */
55
59
  export function isSuperseded(error: unknown): boolean {
56
- return isUltimateError(error) && error.code === 'X_SUPERSEDED';
60
+ return (
61
+ isUltimateError(error) &&
62
+ (error.code === 'X_SUPERSEDED' || error.code === 'X_CLIENT_SCOPE_CHANGED')
63
+ );
57
64
  }