@ultimat3/core 1.2.0 → 2.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 (45) hide show
  1. package/CLAUDE.md +252 -0
  2. package/README.md +210 -10
  3. package/package.json +2 -1
  4. package/src/actor.ts +118 -4
  5. package/src/app-version.ts +32 -0
  6. package/src/assert.ts +5 -1
  7. package/src/config.ts +47 -12
  8. package/src/context.ts +30 -3
  9. package/src/cursor.ts +25 -4
  10. package/src/env-example.ts +2 -1
  11. package/src/env.ts +14 -3
  12. package/src/environment.ts +39 -13
  13. package/src/error-codes.ts +13 -0
  14. package/src/error-render.ts +249 -0
  15. package/src/error-reporter-sentry.ts +175 -0
  16. package/src/error-reporter.ts +212 -0
  17. package/src/error-retry.ts +100 -0
  18. package/src/errors.ts +55 -7
  19. package/src/exports/error-contract.ts +61 -0
  20. package/src/exports/observability.ts +161 -0
  21. package/src/exports/secrets.ts +71 -0
  22. package/src/ids.ts +49 -7
  23. package/src/impersonate.ts +62 -0
  24. package/src/index.ts +277 -113
  25. package/src/lifecycle-deadline.ts +73 -0
  26. package/src/lifecycle-errors.ts +33 -0
  27. package/src/lifecycle.ts +178 -16
  28. package/src/logger.ts +99 -9
  29. package/src/mcp-exposure.ts +32 -0
  30. package/src/metrics.ts +0 -0
  31. package/src/otlp-metric-exporter.ts +136 -0
  32. package/src/otlp-span-exporter.ts +170 -0
  33. package/src/otlp.ts +217 -0
  34. package/src/read-capped.ts +47 -0
  35. package/src/runtime-metrics.ts +15 -0
  36. package/src/safe-url.ts +50 -0
  37. package/src/sampler.ts +126 -0
  38. package/src/schema-error-codes.ts +28 -0
  39. package/src/secrets-errors.ts +143 -0
  40. package/src/secrets-store.ts +173 -0
  41. package/src/secrets.ts +292 -0
  42. package/src/telemetry.ts +43 -11
  43. package/src/timing-safe-equal.ts +18 -0
  44. package/src/type-pins.ts +93 -0
  45. package/src/version.ts +53 -4
package/CLAUDE.md ADDED
@@ -0,0 +1,252 @@
1
+ # @ultimat3/core — agent notes
2
+
3
+ Tier 0. **Imports no `@ultimat3/*` package.** Everything else depends on this, so a change here
4
+ is a change to every package.
5
+
6
+ | Rule | |
7
+ |---|---|
8
+ | Deps | none (`bun-types` only) |
9
+ | Errors | subclass `UltimateError`; never `throw new Error` |
10
+ | Values in a message | `renderCauseValue()` / `renderFixLiteral()`; never raw `JSON.stringify`, `String()` or `${…}` on an `unknown` |
11
+ | A value a CALLER supplied | `describeValue()` — shape, never content. `renderCauseValue` is safe against throwing, not against leaking |
12
+ | Reading a caught value | `renderThrowable()` / `isThrownError()` / `stringField()`; never `error.message`, `error instanceof Error` or `typeof error.code === 'string'` directly — the probe throws before the renderer runs |
13
+ | New code | add to `CORE_CODE_TITLES` in `error-codes.ts`, else the title is auto-humanised |
14
+ | Time | take a `Clock`; `Date.now()` / `new Date()` only inside `clock.ts` |
15
+ | Context | never thread `ctx` as a parameter — `useContext()` |
16
+ | Exports | add to `src/index.ts` explicitly; no `export *`. Three subjects that each span a dozen modules arrive through `src/exports/` — every name is still written out in `index.ts`, so the public surface is one file to read |
17
+ | Files | < 200 LOC, 500 hard ceiling, one responsibility, `kebab-case.ts`, test beside source |
18
+ | Type claims | `type-pins.ts`, never a `.test.ts` — `tsconfig.json` excludes tests, so `tsc` never reads one |
19
+
20
+ Deliberate cycles (safe — nothing is referenced at module-evaluation time):
21
+ `errors.ts ⇄ error-codes.ts`. Keep it that way: no top-level `UltimateError` use in
22
+ `error-codes.ts`.
23
+
24
+ `error-render.ts` imports nothing, including from this package — an error factory that dies
25
+ formatting its own message is the failure it exists to prevent, so it cannot depend on anything
26
+ that could itself throw. The same defect shipped three times (`entity`, `flags`, `cli`) before
27
+ this file existed; `renderCauseValue` is `@ultimat3/entity`'s `renderValue` moved down a tier
28
+ VERBATIM, `a object` included, so a package adopting it changes no message. `toUltimateError`,
29
+ `parseId` and `readPackageVersion` are its first callers. The mechanical half is
30
+ `scripts/error-render.ts` on `x verify`'s `errors` step (`X_ERROR_RENDER_UNSAFE`) — it reads
31
+ parameters typed `unknown` that reach a `cause:` / `fix:`, and it cannot see a value laundered
32
+ through a local helper first (`packages/ui/src/components/ErrorState.tsx` builds a `message`
33
+ const, then assigns it).
34
+
35
+ `describeValue` in `error-render.ts` is a character-for-character duplicate of `describeValue` in
36
+ `packages/schema/src/describe-value.ts`, for the same tier-0 reason `SCHEMA_ERROR_CODE_TITLES` is
37
+ one: schema and core are both tier 0 and `core → schema` is **not** a declared edge in
38
+ `scripts/lib/tiers.ts`, so neither may import the other. Keep the two identical; a pin test in
39
+ `@ultimat3/cli` (which may legally import both) is the mechanical half, the same shape as
40
+ `schema-error-codes-pin.test.ts`. The rule it enforces: a `cause` reaches the log index AND the
41
+ HTTP problem document, redaction is by log FIELD key, and a value baked into a message string has
42
+ no key left to redact — so `parseId`/`uuidTimestamp` describe a rejected id and never echo it.
43
+
44
+ `logger.ts` must not import `context.ts`. `context.ts` injects the ids via
45
+ `setLoggerContextFields()`. It **does** import `secret.ts`, one way only: `secret.ts` owns
46
+ `REDACTED` so a `Secret` can render it without importing the logger, and `logger.ts` re-exports
47
+ the constant so there is still one definition and one public path.
48
+
49
+ `ActorFacts` is the app's extension point on `Actor` — module augmentation, the same trick as
50
+ `CtxServices` and `PermissionRegistry`. Core declares the seam and **never a fact**: augmenting
51
+ `ActorFacts` inside the framework would declare that fact for every app. Every fact reads as
52
+ `T | undefined` through `actorFact()` on purpose — an unresolved fact must deny, and a job, a
53
+ test and an MCP token exchange all mint actors that resolved nothing. `type-pins.ts` pins that
54
+ shape against a locally declared sample interface for exactly that reason.
55
+
56
+ | Concept | Owner | Note |
57
+ |---|---|---|
58
+ | which deploy this is | `environment.ts` (`ULTIMATE_ENV`) | the twin of `ROLE`; never declare a second env var for it |
59
+ | what this process does | `roles.ts` (`ROLE`) | |
60
+ | 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 |
61
+ | the values | `env.ts` | `checkEnv().values` holds REAL secrets — anything that prints goes through `maskedEnvValues()` |
62
+ | `.env.example` | `env-example.ts` | a projection of the schema, never hand-maintained |
63
+ | loading `.env` | **Bun**, not us | `envFileCandidates()` documents the measured order; there is no `.env.staging` |
64
+ | a value that must not be printed | `secret.ts` | redacted by VALUE; `revealSecret()` is the one way out, on purpose greppable |
65
+ | the committed encrypted values | `secrets.ts` (envelope) + `secrets-store.ts` (files, `installSecrets`) | plaintext is a flat map of ENV NAMES; there is no `secrets.get()` |
66
+
67
+ `installSecrets()` is the ONLY path from `secrets.enc.json` to an app value, and it lands in
68
+ `process.env` before `defineEnv` reads it — so a secret has one declaration (`envSchema`), one
69
+ `.env.example` row, one mask and one reader. A second accessor would be five second
70
+ implementations. The real environment always wins, which is what lets one image run in Compose and
71
+ on K8s off one committed file.
72
+
73
+ `secrets-errors.ts` registers its seven codes through `registerErrorCodes()` rather than joining
74
+ `CORE_CODE_TITLES` — the codes and the module that throws them ship together, and `registerErrorCodes`
75
+ is the one mechanism that raises `X_ERROR_CODE_DUPLICATE` if anything else claims one. Consequence
76
+ to know: a test that calls `resetErrorCodes()` drops these titles like any other package's, so take
77
+ `errorCodeSnapshot()` first. The envelope carries a `kid` (a domain-separated, truncated SHA-256 of
78
+ the master key) purely so *wrong key* and *edited file* are two codes and not one shrug — GCM alone
79
+ cannot tell them apart.
80
+
81
+ `schema-error-codes.ts` is the same shape a second time, for codes this package does not even own.
82
+ `@ultimat3/schema` is tier 0 like `core` and so can neither call `registerErrorCodes()` itself nor
83
+ import core to reach it — the four codes' titles are a deliberate, tested duplicate of
84
+ `SCHEMA_ERROR_CODES` in `packages/schema/src/errors.ts`, registered unconditionally at import time
85
+ so any process that imports core (not just `@ultimat3/cli`, which used to be the only registrant)
86
+ renders schema's real titles. Neither tier-0 package can check the duplicate against its source, so
87
+ the pin (`schema-error-codes-pin.test.ts`) lives in `@ultimat3/cli`, which may legally import both.
88
+
89
+ `timing-safe-equal.ts` holds the one constant-time string comparison `@ultimat3/auth` and
90
+ `@ultimat3/storage` both need — core is the lowest tier both can reach, so the shared code lives
91
+ here rather than in either package copying the other's file.
92
+
93
+ `mcp-exposure.ts` is the same shape for a declaration rather than an algorithm: `isMcpExposed` is
94
+ the ONE answer to "did this primitive opt into being an MCP tool?", asked by `action`, `query`
95
+ (t3), `mcp`, `ai`, `manifest` (t4) — five packages that cannot import each other, so core is the
96
+ only tier all of them reach. Three spellings of the same question shipped before it (`=== true`,
97
+ `!== false`, `?? true`), which published tools in `openapi.json` and `x.manifest.json` that no
98
+ surface would serve. Never add a second reader: `@ultimat3/cli`'s `mcp-exposure-pin.test.ts` is
99
+ what makes "one predicate" checkable, since no single package below tier 5 can. The one deliberate
100
+ exception is `@ultimat3/admin`'s own catalog, which is opt-OUT and says why in `mcp-tools.ts`.
101
+
102
+ Metrics mirror tracing exactly — `metrics.ts` is to `telemetry.ts` what a counter is to a span:
103
+ always on, no-op exporter by default, driver on the wire. `runtime-metrics.ts` is the only place
104
+ that names a series the deploy chart reads (`http_requests_total`, `connections`, `queue_depth`);
105
+ `SCALING_METRICS` keys them by `ScalingSignal` so `roles.ts` and `docker/helm` cannot drift.
106
+ Core declares the instruments and never calls them for another package's events. `As of 2026-08`
107
+ the recorders are wired, and there is exactly one call site per package — a second one anywhere is
108
+ the bug:
109
+
110
+ | Recorder | The one caller | Why that seam |
111
+ |---|---|---|
112
+ | `recordRequest` | `@ultimat3/http` `pipeline.ts`, the `finally` around `execute` | every request passes it once, error paths included |
113
+ | `recordConnection` | `@ultimat3/realtime` `socket.ts`, `SocketRegistry.add`/`remove` | the only definition of a live connection; close, idle sweep and drain all pass through it, so the gauge cannot leak |
114
+ | `recordQueueDepth` | `@ultimat3/jobs` `worker.ts`, throttled inside `tick()` | the worker is the only process that reads its own queue |
115
+ | `recordJob` | `@ultimat3/jobs` `worker.ts`, the outcome branch inside `tick()` | the loop is where the queue name is in scope; `JOB_OUTCOME_LABELS` maps the four outcomes onto three labels and drops `suspended`, because parking a run is control flow |
116
+ | `recordLeaseLost` | `@ultimat3/jobs` `heartbeat.ts`, once per lease that lapsed | the lease heartbeat is the only thing that knows a renewal stopped landing; deliberately not an `outcome` on `jobs_total`, because nothing failed and nothing finished — the queue simply re-delivered a job this process was still running |
117
+
118
+ Tracing has three parts and they are three files on purpose: `telemetry.ts` builds spans,
119
+ `sampler.ts` decides whether a trace is worth exporting, and `otlp*.ts` puts it on the wire.
120
+ `span.end()` returns early when `traceFlags & 1` is 0 — the bit is obeyed, not merely forwarded,
121
+ which is what stops an exporter from turning 40k rps into 40k rps of spans. `configureTelemetry`
122
+ takes a `Sampler`; the default reads `OTEL_TRACES_SAMPLER*` **at the first span, never at module
123
+ scope** (same call-time rule as `cursor.ts`'s secret). `resetTelemetry()` drops both.
124
+
125
+ The OTLP exporters are built, not wrapped, and the case is in
126
+ [`docs/idea/18-build-vs-wrap.md`](../../docs/idea/18-build-vs-wrap.md): OTLP/HTTP JSON is `fetch`
127
+ plus `JSON.stringify`, while `@opentelemetry/api` would put a SECOND `Span` type in the framework
128
+ (axiom 1) and `sdk-node` would fight `context.ts` for the AsyncLocalStorage. `otlpTraceRequest` /
129
+ `otlpMetricsRequest` are pure so the wire format is a unit test, exactly as `sentryEnvelope` is.
130
+ **gRPC (`:4317`) is out of scope** — it needs HTTP/2 and protobuf, and both the `:4317` port and a
131
+ non-`http/json` `OTEL_EXPORTER_OTLP_PROTOCOL` throw `X_OTLP_PROTOCOL_UNSUPPORTED` naming `:4318`.
132
+ A boot that must not throw asks `tryOtlpEndpoint(signal)` first.
133
+
134
+ `error-reporter.ts` is the same shape a third time: `ErrorReporter`, a no-op default, a memory
135
+ reporter for tests, and a transport on the wire (`error-reporter-sentry.ts`, an optional separate
136
+ export — the DSN is the app's typed env, never a constant here). `reportError` never throws and
137
+ never awaits. **Four packages call it, seven call sites, `As of 2026-08`** — and unlike the
138
+ recorders it is not one per package, because `realtime` has two files that can see a throw:
139
+
140
+ | Package | Call site |
141
+ |---|---|
142
+ | `@ultimat3/http` | `stages.ts` — `status >= 500` only |
143
+ | `@ultimat3/jobs` | `execute.ts`, inside `executeJob`: the one frame still holding the thrown value, where the loop above it sees a message string |
144
+ | `@ultimat3/realtime` | `sync-node.ts` (three) and `sync-upgrade.ts` (one) |
145
+ | `@ultimat3/flags` | `runtime.ts` — `source: 'process'`, severity `warning` |
146
+
147
+ `configureErrorReporting({ release })` is fed the build id `serve.ts` already computed — never a
148
+ second deploy identity. Trace and span resolve as a **pair**, from one source and never field by
149
+ field: a caller-supplied `traceId` picking up the ambient `spanId` produced reports naming a span
150
+ in a different trace, which is worse than no span because it looks authoritative.
151
+
152
+ `METRICS_PATH` is served by `@ultimat3/cli`'s `metrics-endpoint.ts`, on `METRICS_PORT` (9090) and
153
+ **not** on the role's HTTP port: the chart's ingress routes `/` to `web`, so `/metrics` beside
154
+ `/healthz` would be the app's route patterns and error rates on the internet. Every role opens it,
155
+ including the three that open no other socket — `queue_depth` belongs to one of them.
156
+
157
+ ```bash
158
+ bun test # from packages/core
159
+ bun run typecheck
160
+ ```
161
+
162
+ `markReady()` means **bound**, and readiness means **usable** — two different facts since
163
+ `registerReadinessCheck(name, check)`. `/readyz` is ready only when the state is `ready` AND every
164
+ named check passes, and `HealthReport.checks` carries them by name because "alert on check
165
+ failures by check name" is not writable against a boolean. Checks are **synchronous** on purpose:
166
+ a probe that awaits a network call turns a slow dependency into a wedged endpoint and a restart
167
+ loop, so the owner of the dependency keeps a boolean fresh and this reads it. Liveness ignores
168
+ them — a database outage that failed `/healthz` would restart the whole fleet into the same
169
+ outage. The registration returns its unregister, same shape and same ownership rule as
170
+ `onShutdown`; `readinessCheckCount()` is the leak probe.
171
+
172
+ **The drain deadline is enforced, not merely computed, and there is no unbounded state.**
173
+ `ShutdownReason.deadlineAt` was always handed to every hook and **no hook has ever read it** —
174
+ `jobs`' worker awaits every in-flight job and `driver.close()`, `jobs`' scheduler awaits its round,
175
+ `realtime`'s `listenSyncNode` awaits `node.drain()`'s own grace, `http`'s `server.ts` awaits
176
+ `server.stop()` — so before 2026-08 `configureLifecycle({ deadlineMs: 100 })` bounded nothing:
177
+ measured, one 5-second `accept` hook drained in **5053ms**, state pinned at `draining`. `runPhase`
178
+ now races each hook against the time left before `deadlineAt` (`lifecycle-deadline.ts`'s
179
+ `settleWithin`, split out so the race cannot reach this file's state) and an overrun is
180
+ **ABANDONED** — the drain resolves, the later phases still run, and `installSignalHandlers` reaches
181
+ `process.exit(0)`. Merely logging would leave the kubelet to SIGKILL at the grace period, which is
182
+ the every-deploy job duplicate draining exists to prevent; the abandoned hook keeps running with
183
+ nobody reading it, and that cost is named in the log line rather than hidden. `settleWithin`
184
+ attaches a rejection handler unconditionally: an abandoned hook that rejects later has nobody left
185
+ awaiting it, and the unhandled rejection would kill the process the drain is ending cleanly.
186
+
187
+ Three rules follow and none is optional. **The budget is the WHOLE drain's**, read per hook off
188
+ `deadlineAt`, so a hook that spends it leaves none for the ones behind — the sum of the phases is
189
+ bounded, not each phase separately, which is what `terminationGracePeriodSeconds` means. A budget
190
+ already spent still lets a *synchronous* hook finish (a resolved promise settles on a microtask,
191
+ the 0ms timer on a macrotask), so closing a pool costs nothing it does not already have.
192
+ **`DEFAULT_DEADLINE_MS` (25s) applies whether or not an app sets one** — an opt-in deadline would
193
+ have left `worker`, `scheduler` and `sync` unbounded, i.e. a mechanism claiming more than it
194
+ enforces; abandoned at 25s a worker exits clean, its row's visibility lease lapses and another
195
+ worker re-claims it, which is what at-least-once already promises, and the alternative is the same
196
+ duplicate delivered by SIGKILL with no line naming what overran. The lever is a **larger** value —
197
+ `configureLifecycle({ deadlineMs: 600_000 })` for a 10-minute job — and the `X_SHUTDOWN_TIMEOUT`
198
+ `fix:` says so, because whoever reads it at 3am learns the knob from the line. **The budget is real
199
+ monotonic time (`systemClock`), never the injected `clock`**: `waitForIdle` sleeps on a real
200
+ `setTimeout`, so a frozen clock advanced an hour handed the drain a 16-minute grace period the
201
+ kubelet would never honour. `clock` still owns `uptimeMs`. `drainDeadlineMs()` is the one place the
202
+ budget is decided and the only thing a test can pin — 25s is above any drain a test can wait out.
203
+
204
+ `impersonate(actor, reason, fn)` is the ONE door through `withChildContext({ actor })`. It stamps
205
+ the caller onto the child as `Actor.onBehalfOf`, so `actorLabel` renders
206
+ `service:eng-7→user:cust-99@org-3` and a refund issued during a support session can never read as
207
+ the customer's. The non-blank-reason assert is `@ultimat3/entity`'s `crossTenant()` template
208
+ verbatim — two escapes from the framework's default posture should not look like two things. Do
209
+ not add a second impersonation path.
210
+
211
+ Every `UltimateError` carries `retry` (`terminal | retryable | retry-after`), **defaulting to
212
+ `terminal`** — fail closed, because a client retrying on `status >= 500` hammers `X_DB_DRIFT` and
213
+ `X_TENANCY_UNSCOPED`, which are permanent config faults. `registerErrorRetry()` is the one
214
+ registration path and it refuses to reclassify a core code, the same way `registerErrorStatus`
215
+ refuses to remap one. A new code in any package should be classified beside its declaration.
216
+
217
+ Gotchas:
218
+ - `exactOptionalPropertyTypes` is on — declare optional fields as `x?: T | undefined`.
219
+ - `noPropertyAccessFromIndexSignature` is on — `ctx.services['mail']`, not `.mail`.
220
+ - `Ctx` carries a string index signature so apps can augment `CtxServices` for `ctx.posts`. The
221
+ cost is a real axiom-3 hole: `ctx.anything` type-checks as `unknown`, so a service nobody
222
+ declared and nobody installed reads as a value rather than a build error (`examples/dummy`
223
+ shipped `ctx.storage.ensureBucket()` against a method no package has). Deleting the signature
224
+ is the fix and a breaking change; until then `ctx.services['mail']` is the honest late-bound
225
+ path and a declared augmentation is the only typed one. **Measured 2026-08:** deleting the
226
+ signature compiles core clean on its own, and the augmentation seam survives untouched — an
227
+ augmentation adds NAMED members and `Ctx extends CtxServices` picks them up with no index
228
+ signature at all. What is unmeasured is the rest of the tree: the change is only a build error
229
+ where an app reads an undeclared service, which is the point, but `examples/dummy` ships one
230
+ such read and it would land on the app gate's ratchet. Land it as its own change, alone, with a
231
+ full `bun run verify` — never folded into another branch.
232
+ - Tests that touch the registry, the lifecycle or the listener table must call
233
+ `resetErrorCodes()` / `resetLifecycle()` / `resetListeners()`.
234
+ - `onShutdown`'s return value is the unregister, and every caller that can be started twice owns
235
+ it — `@ultimat3/http`'s `server.ts`, `@ultimat3/realtime`'s `listenSyncNode`, `@ultimat3/jobs`'
236
+ worker, `@ultimat3/cli`'s `hold.ts`. `shutdownHookCount()` is the test-only probe, the same
237
+ shape as `idleWaiterCount()`: a count that climbs across a start/stop cycle is a leak.
238
+ - The error-code registry is process-global and every package fills it once, at import time. A
239
+ test that resets it must take `errorCodeSnapshot()` first and call the returned undo in
240
+ `afterAll` — a reset that is not handed back strips the titles of every package imported before
241
+ that file, and their errors render the humanised fallback (`X_DB_DRIFT: db drift`) for the rest
242
+ of the run. That is a load-order flake: green locally, red on whichever CI ordering hits it.
243
+ - Tests that call `configureCursorSigning()` must restore the previous secret, or call
244
+ `resetCursorSigning()` — the only way back to "unconfigured", which restoring a literal cannot
245
+ express. The secret itself is read inside `sign()`, never at module scope: `openSecrets()` runs
246
+ during boot, so a module-scope read signed a whole process's cursors with the dev key while
247
+ `ULTIMATE_CURSOR_SECRET` was set and `x doctor` merely warned. Same call-time rule as
248
+ `@ultimat3/auth`'s `oauth-cookie.ts` / `oauth-exchange.ts`; new secrets follow it.
249
+ - `PRIMITIVE_KINDS` is the executable copy of the eight-primitive rule — `PrimitiveKind` derives
250
+ from it, so the list and the type cannot drift. A ninth entry fails `registrar.test.ts`, which
251
+ is the point: a new capability arrives as a factory over an existing primitive (`llm()` returns
252
+ an `action`), never as a new kind.
package/README.md CHANGED
@@ -6,20 +6,31 @@ Zero dependencies, zero `@ultimat3/*` imports.
6
6
  | Owns | Module |
7
7
  |---|---|
8
8
  | `UltimateError`, the 3-line rendering, `--json` shape | `errors.ts` |
9
+ | rendering an app's value into a `cause` / `fix` without throwing | `error-render.ts` |
9
10
  | code → `{ title, docs }` registry, `registerErrorCodes()` | `error-codes.ts` |
10
11
  | `Result<T, E>` for boundaries where throwing is wrong | `result.ts` |
11
12
  | request context on `AsyncLocalStorage` | `context.ts` |
12
13
  | `Actor` (`user \| service \| agent \| anonymous`) | `actor.ts` |
14
+ | acting as another actor, with an origin and a reason | `impersonate.ts` |
15
+ | is an error worth retrying? one classification per code | `error-retry.ts` |
13
16
  | typed env validated at boot | `env.ts` |
14
17
  | `.env.example` rendered from that schema, and its drift check | `env-example.ts` |
15
18
  | named environments + `ULTIMATE_ENV` resolution | `environment.ts` |
16
19
  | a value that cannot be printed by accident | `secret.ts` |
20
+ | the committed encrypted secrets envelope, AES-256-GCM | `secrets.ts` |
21
+ | the two secrets files, and decrypted values → `defineEnv` | `secrets-store.ts` |
17
22
  | `defineConfig()` for `app.config.ts` | `config.ts` |
18
23
  | runtime roles + `ROLE` resolution | `roles.ts` |
19
24
  | `Clock` — the only source of "now" | `clock.ts` |
20
25
  | UUIDv7, nanoid, branded ids | `ids.ts` |
21
26
  | structured JSON logging + redaction | `logger.ts` |
22
27
  | OTel-shaped spans, always on, no-op by default | `telemetry.ts` |
28
+ | the sampling decision, and `OTEL_TRACES_SAMPLER*` | `sampler.ts` |
29
+ | OTLP/HTTP JSON: endpoint, headers, value encoding | `otlp.ts` |
30
+ | `SpanExporter` on the wire, batched | `otlp-span-exporter.ts` |
31
+ | `MetricExporter` on the wire | `otlp-metric-exporter.ts` |
32
+ | `reportError` + the `ErrorReporter` seam, no-op by default | `error-reporter.ts` |
33
+ | that seam on the wire, Sentry's envelope and DSN | `error-reporter-sentry.ts` |
23
34
  | OTel-shaped counter / gauge / histogram, same seam | `metrics.ts` |
24
35
  | the `/metrics` scrape body | `metrics-text.ts` |
25
36
  | the series every process emits, incl. what the chart scales on | `runtime-metrics.ts` |
@@ -47,9 +58,21 @@ X_DB_DRIFT: schema differs from migrations
47
58
  ```
48
59
 
49
60
  `format()` is always 3 lines (`format({ docs: true })` adds a 4th). `toJSON()` is the `--json`
50
- form: `{ code, title, cause, fix, docs, meta, stack }`. The title comes from the registry, so
61
+ form: `{ code, title, cause, fix, docs, retry, meta, stack }`. The title comes from the registry, so
51
62
  the terminal, the browser overlay and `--json` cannot drift.
52
63
 
64
+ `retry` is `terminal | retryable | retry-after`, and it **defaults to `terminal`** — a client that
65
+ retried on `status >= 500` hammered `X_DB_DRIFT` and `X_TENANCY_UNSCOPED`, which are permanent
66
+ config faults, during the incident they were already causing. Classify the codes your package or
67
+ app throws once, beside the module that declares them:
68
+
69
+ ```ts
70
+ registerErrorRetry({ X_OAUTH_EXCHANGE_FAILED: 'retryable', X_RATE_LIMITED: 'retry-after' });
71
+ ```
72
+
73
+ Core's own classifications are closed, exactly as `registerErrorStatus`'s framework table is: a
74
+ second, different registration for one code throws `X_ERROR_RETRY_INVALID`.
75
+
53
76
  | Code | Subclass |
54
77
  |---|---|
55
78
  | `X_CONFIG_INVALID` | `ConfigInvalidError` |
@@ -64,6 +87,54 @@ Registering a code twice throws `X_ERROR_CODE_DUPLICATE`.
64
87
  `isUltimateError()` is duck-typed on `Symbol.for('ultimate.error')`, not `instanceof` — that is
65
88
  how `@ultimat3/schema` (tier 0, cannot import core) still produces matching errors.
66
89
 
90
+ ### A value you did not produce goes through the renderer
91
+
92
+ An error factory may never throw while formatting its own message: the caller then catches a
93
+ `TypeError` instead of the refusal, `error.code === 'X_…'` matches nothing, and an HTTP surface
94
+ answers 500 where the mapped status belonged. `JSON.stringify` throws on a bigint and on a cycle
95
+ and RUNS any `toJSON` the value carries; `` `${value}` `` throws on a symbol and on a hostile
96
+ `toString`. Both are reachable from app data.
97
+
98
+ ```ts
99
+ import { renderCauseValue, renderFixLiteral, UltimateError } from '@ultimat3/core';
100
+
101
+ declare const kind: unknown;
102
+ declare const value: unknown;
103
+
104
+ throw new UltimateError({
105
+ code: 'X_ID_INVALID',
106
+ cause: `expected a ${renderCauseValue(kind)} UUIDv7, received ${renderCauseValue(value)}`,
107
+ fix: `pass an id produced by typedId<${renderFixLiteral(kind, '<kind>')}>()`,
108
+ });
109
+ ```
110
+
111
+ | Helper | For | Degrades to |
112
+ |---|---|---|
113
+ | `renderCauseValue(value)` | a `cause`, which only has to describe | `a object that cannot be rendered` |
114
+ | `renderFixLiteral(value, placeholder)` | a `fix`, which has to parse and run | the placeholder you name |
115
+ | `renderThrowable(value)` | a caught value: an `Error`'s own words, anything else rendered | `renderCauseValue(value)` |
116
+ | `isThrownError(value)` | `value instanceof Error` where the test itself may throw | `false` |
117
+ | `stringField(value, key)` | one string field off a caught value | `undefined` |
118
+
119
+ The last two are the READ side, and the reason they exist is that the renderers above them were
120
+ being reached past an unguarded probe. `catch (error)` hands you a value the framework did not
121
+ build: `error instanceof Error` runs a `Proxy`'s `getPrototypeOf` trap, and
122
+ `typeof error.code === 'string'` — the structural check every surface uses to recognise an
123
+ `UltimateError` that crossed a worker, a subprocess or a socket — is a getter call. Either one
124
+ throws one line *before* the total renderer that was meant to make the path safe.
125
+
126
+ ```ts
127
+ const code = stringField(error, 'code') ?? 'X_TRANSPORT_UNAVAILABLE';
128
+ const cause = stringField(error, 'cause') ?? renderThrowable(error);
129
+ ```
130
+
131
+ `stringField` answers `undefined` for absent, wrong type and threw, because all three mean the
132
+ same thing to the caller: this value did not supply the field, so use the default.
133
+
134
+ Enforced, not documented: `x verify`'s `errors` step fails with `X_ERROR_RENDER_UNSAFE` when a
135
+ parameter typed `unknown` reaches a `cause:` or `fix:` through `JSON.stringify`, `String()` or a
136
+ bare interpolation (`scripts/error-render.ts`).
137
+
67
138
  ## Context
68
139
 
69
140
  ```ts
@@ -90,6 +161,36 @@ because it closes over the ctx (actor, clock, tz) it was built for. `withChildCo
90
161
  factory-managed name from what it carries forward on purpose: only an ad hoc service nobody
91
162
  registered survives an actor swap unrebuilt.
92
163
 
164
+ ## Actor facts — the app's own authz vocabulary, on the framework's actor
165
+
166
+ Roles and an org id answer a columnar question ("same tenant?"). They cannot answer a relational
167
+ one ("a friend of the author?"), and a policy predicate is synchronous, so it may not go and
168
+ fetch one. Resolve the graph ONCE per request and hand it to the actor every surface already
169
+ carries:
170
+
171
+ ```ts
172
+ declare module '@ultimat3/core' {
173
+ interface ActorFacts { readonly viewer: Viewer } // declared once, app-wide
174
+ }
175
+
176
+ // at the request boundary, where the await already happens
177
+ const actor = withFacts(userActor({ id: user.id, roles: [user.role] }), { viewer });
178
+
179
+ // in a predicate, on any surface — HTTP, MCP, admin, a job
180
+ can('post:read', ({ row, actor }) => row !== null && canSee(actorFact(actor, 'viewer'), row));
181
+ ```
182
+
183
+ | Rule | Why |
184
+ |---|---|
185
+ | `actorFact(actor, key)` takes `Actor \| null` | that is exactly what a predicate is handed |
186
+ | every fact is `T \| undefined` | nothing can prove one was resolved — a job, a test and a token exchange mint actors too, so an absent fact is a **denial** the compiler makes you write |
187
+ | `facts` is optional on `Actor` | additive: an actor literal written before the seam is still an `Actor` |
188
+ | the framework declares no fact | `ActorFacts` is the app's; core only owns the seam. `type-pins.ts` pins the machinery against a local sample rather than augmenting the real interface |
189
+
190
+ Not a second authz path: the facts ride the actor the policy layer already reads, so no surface
191
+ package learns the app's vocabulary and one `Policy` object still answers everywhere. Facts are
192
+ request-scoped and never logged — `actorLabel()` stays id-only.
193
+
93
194
  ## Env fails once, completely
94
195
 
95
196
  ```ts
@@ -120,11 +221,17 @@ Loading `.env` is **Bun's**, not ours. `envFileCandidates()` states what it does
120
221
  ## One environment, one key
121
222
 
122
223
  ```ts
123
- resolveEnvironment(); // 'development' | 'test' | 'staging' | 'production'
124
- isProduction(); // exact; nothing else counts
125
- isLocal(); // development or test — never staging
224
+ resolveEnvironment(); // 'development' | 'test' | 'staging' | 'production'
225
+ tryResolveEnvironment(); // the same, `undefined` instead of a throw for an unrecognised value
226
+ isProduction(); // exact; nothing else counts
227
+ isLocal(); // development or test — never staging
126
228
  ```
127
229
 
230
+ `tryResolveEnvironment` is for a caller that must *answer* rather than fail — a `robots.txt` render
231
+ is the case: `ULTIMATE_ENV` is not in the env schema, so nothing validates it at boot, and a typo
232
+ would otherwise 500 the one response whose body was already going to be `Disallow: /`. It names no
233
+ fallback of its own; the caller does.
234
+
128
235
  `ULTIMATE_ENV` is the key, `NODE_ENV` the fallback (platforms already set it). Values are
129
236
  `NODE_ENV`'s spellings plus `staging` — `prod` and `dev` are typos, not aliases, and
130
237
  `ULTIMATE_ENV=prod` is `X_ENVIRONMENT_INVALID`. An unrecognised `NODE_ENV` is *not* an error: it
@@ -146,19 +253,63 @@ frozen and everything but `label` is non-enumerable, so `{ ...dsn }` cannot spre
146
253
  out. There is no vault integration and there will not be one — that is a platform primitive
147
254
  (axiom 7); a `Secret` plus the platform's own secret store is the whole design.
148
255
 
256
+ ## Encrypted secrets are env values that arrive early
257
+
258
+ ```ts
259
+ // app.config.ts
260
+ await installSecrets(); // secrets.enc.json → process.env, real env wins
261
+ export const envSchema = {
262
+ SESSION_SECRET: { type: 'string', secret: true },
263
+ } satisfies EnvSchema;
264
+ export const env = defineEnv(envSchema);
265
+ ```
266
+
267
+ `secrets.enc.json` is committed; `.secrets.key` is not, and `ULTIMATE_SECRETS_KEY` is read before
268
+ it so a container is handed its key by the platform. The plaintext is a flat map of environment
269
+ variable names to values, so a secret keeps **one** declaration (`envSchema`), one `.env.example`
270
+ row, one mask (`maskedEnvValues`), one redaction entry and one reader (`env.SESSION_SECRET`).
271
+ There is deliberately no `secrets.get()`: a second accessor would mint values with no declaration,
272
+ no type and no mask, and each of those five would need a second implementation. `x secrets` is the
273
+ only writer.
274
+
275
+ | Envelope | |
276
+ |---|---|
277
+ | Cipher | AES-256-GCM through WebCrypto, 128-bit tag, a fresh 12-byte IV per seal |
278
+ | Key | 32 CSPRNG bytes, hex. No KDF — the key is the key |
279
+ | AAD | `v`, `alg` and `kid`, so a downgraded header fails the tag rather than changing how the body is read |
280
+ | `kid` | a domain-separated, truncated SHA-256 of the master key. Safe to commit, and what makes *wrong key* (`X_SECRETS_KEY_MISMATCH`) a different code from *edited file* (`X_SECRETS_TAMPERED`) |
281
+
282
+ A missing file is not an error — an app may declare no secrets. A file with **no key to open it**
283
+ is `X_SECRETS_KEY_MISSING` and fatal: a process that booted past its secrets authenticates against
284
+ nothing and still reports healthy.
285
+
149
286
  ## Time, ids, telemetry, drain
150
287
 
151
288
  - Never call `Date.now()`. Take a `Clock`; tests pass `frozenClock('2026-07-26T10:00:00Z')`.
152
289
  - `uuid()` is UUIDv7: time-prefixed, monotonic within a millisecond, never backwards on clock
153
290
  skew. `typedId<'post'>()` brands it so a post id cannot be passed where a user id is wanted.
154
291
  - `withSpan('action.publishPost', fn)` is free until `configureTelemetry({ exporter })`.
155
- Traces cross process boundaries via `traceparent()` / `parseTraceparent()` Sentry, Honeycomb
156
- and OTLP all plug in as a `SpanExporter`.
292
+ Traces cross process boundaries via `traceparent()` / `parseTraceparent()`, whose ids come from
293
+ `traceId()` / `spanId()` **never `uuid()`**, whose dashed 36 characters every collector
294
+ rejects. `isTraceId()` / `isSpanId()` are the one definition of the valid shape.
295
+ - **Sampling is honoured, not just propagated.** `startSpan` takes the parent's bit when there is
296
+ one, else asks the `Sampler`; `span.end()` exports nothing when the bit is 0. The default reads
297
+ `OTEL_TRACES_SAMPLER` / `OTEL_TRACES_SAMPLER_ARG` at the first span, and
298
+ `configureTelemetry({ sampler })` replaces it.
299
+ - **The OTLP exporters ship**, speaking OTLP/HTTP JSON, no dependency:
300
+ `otlpSpanExporter({ endpoint })` (batched, with `flush()` / `shutdown()`) and
301
+ `otlpMetricExporter({ endpoint })`. Both default to `OTEL_EXPORTER_OTLP_ENDPOINT`;
302
+ `tryOtlpEndpoint('traces')` answers `undefined` when nobody configured one, so a boot can skip
303
+ the exporter instead of throwing. **gRPC (`:4317`) is out of scope** and says so with
304
+ `X_OTLP_PROTOCOL_UNSUPPORTED`.
157
305
  - Metrics are the same shape one signal over: `counter()`, `gauge()`, `histogram()`, aggregated
158
306
  in process, free until `configureMetrics({ exporter })`. See below.
159
307
  - `onShutdown(name, hook, { phase })` with phases `accept → inflight → close` under one
160
308
  deadline; `readyzPayload()` flips to 503 the moment draining starts, `healthzPayload()` stays
161
- 200 until stopped.
309
+ 200 until stopped. It **returns an unregister**, and a caller that starts and stops more than
310
+ once has to keep it: a discarded one is a hook per `start()`, each retaining the resource it
311
+ was going to drain, and the next drain runs every one of them against a torn-down copy.
312
+ `shutdownHookCount()` is the test-only probe that makes the leak assertable.
162
313
  - Anything that opens a socket calls `markListening(server.url.origin)` and releases it on close.
163
314
  That is what tells the sealed test network a loopback request is this process, not egress.
164
315
 
@@ -181,8 +332,8 @@ collectMetrics(); // the same numbers as data, for a MetricExporter
181
332
  | Temporality | cumulative, as OTel defines it — a read never resets a counter, so two scrapers cannot steal each other's samples |
182
333
  | Names | lowercase `snake_case`, the intersection every exposition format accepts. Dotted OTel names survive OTLP and die at a Prometheus scrape |
183
334
  | Attributes | `string \| number \| boolean` only — each distinct set is a stored series, so a user id here is an outage |
184
- | Driver seam | `MetricExporter`, defaulting to a no-op. `memoryMetricExporter()` for tests, `startMetricExport(ms)` for a periodic push |
185
- | Not shipped | an OTLP client. It is bytes on a wire and a dependency; the seam is here, the driver is yours — or scrape `/metrics` with an agent that already speaks it |
335
+ | Cardinality | enforced, not advised: `maxSeries` per instrument (default `DEFAULT_MAX_SERIES`), and past it every new label set folds into one `otel_metric_overflow="true"` series with `X_METRIC_CARDINALITY` logged once, naming the instrument |
336
+ | Driver seam | `MetricExporter`, defaulting to a no-op. `memoryMetricExporter()` for tests, `startMetricExport(ms)` for a periodic push, `otlpMetricExporter()` for a collector |
186
337
 
187
338
  `runtime-metrics.ts` holds the series every process emits, and `SCALING_METRICS` maps each
188
339
  `ScalingSignal` from `roles.ts` to the one that carries it — so the role table, the chart and the
@@ -201,6 +352,54 @@ at `METRICS_PATH` on `METRICS_PORT` (9090), for every role rather than only the
201
352
  HTTP socket. Labels are route **patterns**, status **classes** and queue names: nothing
202
353
  per-user, per-id or attacker-chosen ever becomes a series.
203
354
 
355
+ ## Error reporting: the third seam, same shape as the other two
356
+
357
+ A no-op by default, one transport on the wire, one memory double for tests — `telemetry.ts` and
358
+ `metrics.ts`' shape a third time. What a monitor receives is the framework's error contract
359
+ verbatim, so it groups on `code` and shows `fix` to whoever is paged.
360
+
361
+ **An Ultimate app installs nothing.** `@ultimat3/cli`'s `serve.ts` calls
362
+ `configureErrorReporting` at boot from one env var — `SENTRY_DSN`, unset meaning the no-op stays
363
+ and nobody is paged — and passes the build id it already computed as `release`. The call below is
364
+ for a host that boots something other than `runRole`.
365
+
366
+ ```ts
367
+ import { configureErrorReporting, reportError, sentryErrorReporter } from '@ultimat3/core';
368
+
369
+ declare const sentryDsn: string;
370
+ declare const buildId: string;
371
+ declare const failure: unknown;
372
+
373
+ configureErrorReporting({
374
+ reporter: sentryErrorReporter({ dsn: sentryDsn }), // config, never a constant in this package
375
+ release: buildId, // the id `x-ultimate-build` carries
376
+ });
377
+
378
+ reportError(failure, { source: 'http', severity: 'error', scope: { operation: 'POST /api/posts' } });
379
+ ```
380
+
381
+ | | |
382
+ |---|---|
383
+ | `reportError(error, { source, severity?, scope? })` | never throws, never awaits. A monitor that is down must not turn one failure into two |
384
+ | `ERROR_SOURCES` | `http` `job` `realtime` `cli` `process` — closed. A new surface adds a member, never a string of its own |
385
+ | `ErrorSeverity` | `warning` `error` `fatal`. `warning` is a failure the framework already recovered from — a retry, not a dead letter |
386
+ | `ErrorScope` | `requestId` `traceId` `spanId` `role` `operation` `actorId` `extra`. `operation` is a route **pattern** or a job name, never a concrete path or a row id |
387
+ | `ErrorReport` | `code` `title` `cause` `fix` `docs` + `meta` `stack` `resource` `environment` `release` `scope`, and the thrown value under `error` |
388
+ | `configureErrorReporting({ reporter, clock, release, environment, enabled })` | the one install point; `resetErrorReporting()` puts the no-op back |
389
+ | Reporters | `noopErrorReporter` (default), `memoryErrorReporter()` (`.events`, `.reset()`), `sentryErrorReporter({ dsn, fetch?, clientName? })` |
390
+ | Wire, testable alone | `parseSentryDsn(dsn)` → `{ publicKey, envelopeUrl, … }`, `sentryEnvelope(report, { dsn, eventId })` → the envelope body. Pure, exactly as `otlpTraceRequest` is |
391
+ | `errorReport(error, options)` | the normalisation on its own, for a transport's test or a surface that enriches before sending |
392
+
393
+ `As of 2026-08` four packages call `reportError`, seven call sites in all: `@ultimat3/http`'s
394
+ `stages.ts` (`status >= 500` only), `@ultimat3/jobs`' `executeJob` — the one frame still holding the
395
+ thrown value — `@ultimat3/realtime`'s `sync-node.ts` and `sync-upgrade.ts`, and `@ultimat3/flags`'
396
+ `runtime.ts` (`source: 'process'`, severity `warning`). Trace and span resolve as a **pair** from
397
+ one source: a caller-supplied `traceId` never picks up the ambient `spanId`, because a report
398
+ claiming a span from a different trace sends whoever is paged somewhere authoritative and wrong.
399
+
400
+ `ErrorReporterDsnInvalidError` (`X_ERROR_REPORTER_DSN_INVALID`) is the only code this seam owns —
401
+ raised at `parseSentryDsn`, at configuration, never at a report.
402
+
204
403
  ## One cursor, everywhere
205
404
 
206
405
  ```ts
@@ -219,9 +418,10 @@ never a silently wrong page.
219
418
  | | |
220
419
  |---|---|
221
420
  | Signature | truncated HMAC-SHA256, compared in constant time |
222
- | Secret | `ULTIMATE_CURSOR_SECRET`, or `configureCursorSigning()` at boot. Rotating it invalidates every open cursor |
421
+ | Secret | `configureCursorSigning()` at boot, else `ULTIMATE_CURSOR_SECRET`. **Read when a cursor is signed, never at import** — an app whose `openSecrets()` sets the variable during boot would otherwise sign every cursor with the dev key. Rotating it invalidates every open cursor |
223
422
  | Signed, not encrypted | the client already has these rows; what it must not do is *invent* a position |
224
423
  | `usesDevCursorSecret()` | true while the shipped dev key is in use |
424
+ | `resetCursorSigning()` | test seam: forget `configureCursorSigning` and fall back to the environment |
225
425
 
226
426
  ## One image pipeline, everywhere
227
427
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/core",
3
- "version": "1.2.0",
3
+ "version": "2.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",
@@ -19,6 +19,7 @@
19
19
  "files": [
20
20
  "src",
21
21
  "!src/**/*.test.ts",
22
+ "CLAUDE.md",
22
23
  "README.md",
23
24
  "LICENSE"
24
25
  ],