@ultimat3/core 1.1.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.
- package/CLAUDE.md +252 -0
- package/README.md +217 -10
- package/package.json +2 -1
- package/src/actor.ts +118 -4
- package/src/app-version.ts +32 -0
- package/src/assert.ts +5 -1
- package/src/config.ts +47 -12
- package/src/context.ts +30 -3
- package/src/cursor.ts +25 -4
- package/src/env-example.ts +2 -1
- package/src/env.ts +14 -3
- package/src/environment.ts +39 -13
- package/src/error-codes.ts +13 -0
- package/src/error-render.ts +249 -0
- package/src/error-reporter-sentry.ts +175 -0
- package/src/error-reporter.ts +212 -0
- package/src/error-retry.ts +100 -0
- package/src/errors.ts +55 -7
- package/src/exports/error-contract.ts +61 -0
- package/src/exports/observability.ts +161 -0
- package/src/exports/secrets.ts +71 -0
- package/src/ids.ts +49 -7
- package/src/impersonate.ts +62 -0
- package/src/index.ts +277 -113
- package/src/lifecycle-deadline.ts +73 -0
- package/src/lifecycle-errors.ts +33 -0
- package/src/lifecycle.ts +178 -16
- package/src/logger.ts +99 -9
- package/src/mcp-exposure.ts +32 -0
- package/src/metrics.ts +0 -0
- package/src/otlp-metric-exporter.ts +136 -0
- package/src/otlp-span-exporter.ts +170 -0
- package/src/otlp.ts +217 -0
- package/src/read-capped.ts +47 -0
- package/src/runtime-metrics.ts +15 -0
- package/src/safe-url.ts +50 -0
- package/src/sampler.ts +126 -0
- package/src/schema-error-codes.ts +28 -0
- package/src/secrets-errors.ts +143 -0
- package/src/secrets-store.ts +173 -0
- package/src/secrets.ts +292 -0
- package/src/telemetry.ts +43 -11
- package/src/timing-safe-equal.ts +18 -0
- package/src/type-pins.ts +93 -0
- 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();
|
|
124
|
-
|
|
125
|
-
|
|
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()
|
|
156
|
-
|
|
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
|
-
|
|
|
185
|
-
|
|
|
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
|
|
@@ -194,6 +345,61 @@ process cannot drift apart:
|
|
|
194
345
|
| `ws-connections` | `connections` | gauge, `+1`/`-1` |
|
|
195
346
|
| `queue-depth` | `queue_depth` | gauge, by `queue` label |
|
|
196
347
|
|
|
348
|
+
`As of 2026-08` all three are emitted and scraped. One call site per package — `recordRequest`
|
|
349
|
+
from `@ultimat3/http`'s pipeline, `recordConnection` from `@ultimat3/realtime`'s socket table,
|
|
350
|
+
`recordQueueDepth` from `@ultimat3/jobs`' worker loop — and `@ultimat3/cli` serves `metricsText()`
|
|
351
|
+
at `METRICS_PATH` on `METRICS_PORT` (9090), for every role rather than only the ones that open an
|
|
352
|
+
HTTP socket. Labels are route **patterns**, status **classes** and queue names: nothing
|
|
353
|
+
per-user, per-id or attacker-chosen ever becomes a series.
|
|
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
|
+
|
|
197
403
|
## One cursor, everywhere
|
|
198
404
|
|
|
199
405
|
```ts
|
|
@@ -212,9 +418,10 @@ never a silently wrong page.
|
|
|
212
418
|
| | |
|
|
213
419
|
|---|---|
|
|
214
420
|
| Signature | truncated HMAC-SHA256, compared in constant time |
|
|
215
|
-
| Secret | `ULTIMATE_CURSOR_SECRET
|
|
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 |
|
|
216
422
|
| Signed, not encrypted | the client already has these rows; what it must not do is *invent* a position |
|
|
217
423
|
| `usesDevCursorSecret()` | true while the shipped dev key is in use |
|
|
424
|
+
| `resetCursorSigning()` | test seam: forget `configureCursorSigning` and fall back to the environment |
|
|
218
425
|
|
|
219
426
|
## One image pipeline, everywhere
|
|
220
427
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/core",
|
|
3
|
-
"version": "
|
|
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
|
],
|