@ultimat3/core 3.0.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CLAUDE.md CHANGED
@@ -101,6 +101,32 @@ the pin (`schema-error-codes-pin.test.ts`) lives in `@ultimat3/cli`, which may l
101
101
  `@ultimat3/storage` both need — core is the lowest tier both can reach, so the shared code lives
102
102
  here rather than in either package copying the other's file.
103
103
 
104
+ `canonical-json.ts` is the same shape for the hash every SHARING key in the framework is taken
105
+ over, `As of 2026-08`. `canonicalJson` is an INJECTIVE canonical form and `fingerprint` is
106
+ SHA-256/16 of it, and three tier-3 packages needed exactly this while none may import another:
107
+ `@ultimat3/action`'s `requestHash` and job dedupe key, `@ultimat3/query`'s `queryHash` (a
108
+ read-cache entry, a cursor scope, a live query id) and `@ultimat3/realtime`'s `qid`. Each kept its
109
+ own copy and the copies had **diverged in a way that leaked**: query's had no `Date` branch, so
110
+ `Object.keys(date)` was `[]`, every date rendered `{}`, and one cache key, one cursor scope and one
111
+ live window answered for every date window of a read — reachable straight off a query string, since
112
+ `coerceQuery` turns a `t.date` member into a real `Date`. Injective is the whole requirement, not a
113
+ formatting preference: every one of those keys decides which of two callers is served the other's
114
+ answer. So `NaN`, `±Infinity` and `-0` are bare tokens the quoting `string` branch cannot spell,
115
+ and a `Date`, a `Map` and a `Set` — the three values with no own enumerable key — are TAGGED. Never
116
+ add a fourth copy, and never make it parseable: `@ultimat3/action`'s `stableStringify` is the
117
+ DOCUMENT form for that (it publishes `openapi.json`), and it is a different function on purpose.
118
+
119
+ `decimal-order.ts` is the third instance of the same rule, over a value rather than a shape.
120
+ `compareDecimalText` is the exact ordering of two decimals however long the digits run — the order
121
+ Postgres gives a `numeric` or an `int8` over the TEXT `@ultimat3/entity`'s `bigint()` and
122
+ `decimal()` hand back, where `String(left) < String(right)` answers `["10","100","2","9"]` for
123
+ `["2","9","10","100"]` and cuts a keyset page where the database does not. It answers **`undefined`**
124
+ when either side is not a plain decimal, and that is the contract, not a convenience: a caller that
125
+ knows the column's declared kind asks (`@ultimat3/entity`'s `compareByKind`), and a caller that does
126
+ NOT — `@ultimat3/query`, whose `OrderKey` is a name and a direction — must never, because Postgres
127
+ orders a `text` column of digits lexically and a comparator guessing would trade one disagreement
128
+ with the SQL it printed for another.
129
+
104
130
  `mcp-exposure.ts` is the same shape for a declaration rather than an algorithm: `isMcpExposed` is
105
131
  the ONE answer to "did this primitive opt into being an MCP tool?", asked by `action`, `query`
106
132
  (t3), `mcp`, `ai`, `manifest` (t4) — five packages that cannot import each other, so core is the
package/README.md CHANGED
@@ -343,6 +343,7 @@ collectMetrics(); // the same numbers as data, for a MetricExporter
343
343
  | Names | lowercase `snake_case`, the intersection every exposition format accepts. Dotted OTel names survive OTLP and die at a Prometheus scrape |
344
344
  | Attributes | `string \| number \| boolean` only — each distinct set is a stored series, so a user id here is an outage |
345
345
  | 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 |
346
+ | Async gauges | an `observe()` that throws, or answers a non-finite number, costs that instrument its point and **nothing beside it** — `X_METRIC_VALUE_INVALID` logged once, naming the instrument. Unguarded it took the whole `/metrics` body down with it, and `startMetricExport`'s timer callback raised where nothing can catch it |
346
347
  | Driver seam | `MetricExporter`, defaulting to a no-op. `memoryMetricExporter()` for tests, `startMetricExport(ms)` for a periodic push, `otlpMetricExporter()` for a collector |
347
348
 
348
349
  `runtime-metrics.ts` holds the series every process emits, and `SCALING_METRICS` maps each
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/core",
3
- "version": "3.0.0",
3
+ "version": "4.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",
package/src/actor.ts CHANGED
@@ -76,8 +76,27 @@ export interface Actor {
76
76
  readonly orgId?: string | undefined;
77
77
  /** Application roles (`admin`, `editor`). Unrelated to the runtime `Role`. */
78
78
  readonly roles: readonly string[];
79
- /** Capability strings a policy can require (`post:publish`). */
79
+ /**
80
+ * FRAMEWORK capabilities, checked with `hasScope()`. One reader in the whole framework —
81
+ * `@ultimat3/entity`'s `crossTenant()`, gating `tenancy:cross` — and that narrowness is the
82
+ * point: a scope is an escape hatch the framework itself honours, never the app's authz
83
+ * vocabulary. That is `roles` and `permissions`, which `@ultimat3/policy` reads.
84
+ */
80
85
  readonly scopes: readonly string[];
86
+ /**
87
+ * DIRECT grants, bypassing roles: what a service token or a break-glass account holds
88
+ * (`post:publish`). `@ultimat3/policy` flattens these together with every grant `roles` expands
89
+ * to, so the two are one set by the time a predicate runs, and neither is `scopes`.
90
+ *
91
+ * Declared here rather than on policy's own `Actor` (`As of 2026-08-19`). It was policy's, which
92
+ * made it unreachable from the one place actors are built: core is tier 0 and cannot import
93
+ * policy, so `build()` below had no field to carry and `userActor({ permissions })` compiled and
94
+ * silently discarded the argument. Every caller worked around it with
95
+ * `{ ...userActor({ id }), permissions: [...] }` — a spread over a frozen actor, producing an
96
+ * UNFROZEN one — so the fixtures proving authz had a shape no request ever mints. `@ultimat3/auth`
97
+ * carried a second, hand-synced copy of the declaration for the same tier reason.
98
+ */
99
+ readonly permissions: readonly string[];
81
100
  /** App-declared facts. Read it through `actorFact()`; never logged — `actorLabel` is id-only. */
82
101
  readonly facts?: ActorFactMap | undefined;
83
102
  /**
@@ -93,6 +112,8 @@ export interface ActorInit {
93
112
  readonly orgId?: string | undefined;
94
113
  readonly roles?: readonly string[] | undefined;
95
114
  readonly scopes?: readonly string[] | undefined;
115
+ /** Direct grants. Absent means none — never "inherit some"; there is nothing to inherit from. */
116
+ readonly permissions?: readonly string[] | undefined;
96
117
  readonly facts?: ActorFactMap | undefined;
97
118
  /** For a session that already recorded an impersonation; `impersonate()` sets it otherwise. */
98
119
  readonly onBehalfOf?: ActorOrigin | undefined;
@@ -105,6 +126,7 @@ const ANONYMOUS: Actor = Object.freeze({
105
126
  id: 'anonymous',
106
127
  roles: Object.freeze([]),
107
128
  scopes: Object.freeze([]),
129
+ permissions: Object.freeze([]),
108
130
  facts: NO_FACTS,
109
131
  });
110
132
 
@@ -115,6 +137,10 @@ function build(kind: ActorKind, init: ActorInit): Actor {
115
137
  orgId: init.orgId,
116
138
  roles: Object.freeze([...(init.roles ?? [])]),
117
139
  scopes: Object.freeze([...(init.scopes ?? [])]),
140
+ // Copied and frozen like the two above, and for the sharper reason: this list IS the actor's
141
+ // authz. Handing back the caller's array would let whoever still holds it `push` a grant into
142
+ // a decision already made about a frozen actor.
143
+ permissions: Object.freeze([...(init.permissions ?? [])]),
118
144
  facts: Object.freeze({ ...init.facts }),
119
145
  onBehalfOf: init.onBehalfOf === undefined ? undefined : Object.freeze({ ...init.onBehalfOf }),
120
146
  });
@@ -0,0 +1,102 @@
1
+ /**
2
+ * The INJECTIVE canonical form of a value, and the sharing key taken over it.
3
+ *
4
+ * Tier 0 because three tier-3 packages need exactly this and none of them may import another:
5
+ * `@ultimat3/action`'s `requestHash` and job dedupe key, `@ultimat3/query`'s `queryHash` (the
6
+ * read-cache entry, the cursor scope, the live query id) and `@ultimat3/realtime`'s `qid`. Each
7
+ * kept its own copy, and the copies had already diverged — query's rendered every `Date` as `{}`,
8
+ * so one key answered for every date window a read ever served.
9
+ *
10
+ * Injective is the whole requirement: every one of those keys decides which of two callers is
11
+ * served the other's answer, so two distinct inputs sharing one string is a leak and not a
12
+ * collision. Nothing here is ever parsed back — `@ultimat3/action`'s `stableStringify` is the
13
+ * DOCUMENT form for that, and it is a different function on purpose.
14
+ */
15
+
16
+ /**
17
+ * Bare tokens, never quoted and never `'null'`. This output is only ever hashed, so an unquoted
18
+ * word cannot collide with the `string` branch (which always quotes), while `'null'` collided with
19
+ * JSON `null` itself — `{ n: NaN }`, `{ n: Infinity }`, `{ n: -Infinity }` and `{ n: null }` were
20
+ * one key and therefore one idempotency record, one cache entry and one cursor scope. `-0` is
21
+ * spelled out for the same reason: `String(-0)` is `"0"`.
22
+ */
23
+ function hashNumber(value: number): string {
24
+ if (Number.isNaN(value)) return 'NaN';
25
+ if (!Number.isFinite(value)) return value > 0 ? 'Infinity' : '-Infinity';
26
+ return Object.is(value, -0) ? '-0' : String(value);
27
+ }
28
+
29
+ /**
30
+ * The epoch, TAGGED — the same per-type tagging `hashNumber` uses for `NaN` and `-0`, for the same
31
+ * reason. Untagged, a `t.date` field and a `t.number` field holding that field's epoch would be one
32
+ * key, which is the collision this form exists to refuse; an Invalid Date would be the bare `NaN`
33
+ * token a `t.number` field already owns.
34
+ */
35
+ function hashDate(value: Date): string {
36
+ return `Date(${hashNumber(value.getTime())})`;
37
+ }
38
+
39
+ /**
40
+ * Tagged for the reason a `Date` is: a `Map`, a `Set` and `{}` all have no own enumerable key, so
41
+ * the object branch rendered all three `{}` and one hash answered for three payloads sharing
42
+ * nothing. Entries are SORTED, as an object's keys are: insertion order is not part of what a Map
43
+ * or a Set holds, so two spellings of one payload stay one key.
44
+ */
45
+ function hashCollection(value: Map<unknown, unknown> | Set<unknown>): string {
46
+ const entries =
47
+ value instanceof Map
48
+ ? [...value].map(([key, item]) => `${canonicalJson(key)}:${canonicalJson(item)}`)
49
+ : [...value].map((item) => canonicalJson(item));
50
+ return `${value instanceof Map ? 'Map' : 'Set'}(${entries.sort().join(',')})`;
51
+ }
52
+
53
+ /**
54
+ * JSON with object keys sorted at every depth, and every value a JSON document would fold onto
55
+ * `null` or `{}` given a token of its own. No timestamps, no insertion-order leaks.
56
+ */
57
+ export function canonicalJson(value: unknown): string {
58
+ if (value === null) return 'null';
59
+ switch (typeof value) {
60
+ case 'string':
61
+ return JSON.stringify(value);
62
+ case 'number':
63
+ return hashNumber(value);
64
+ case 'boolean':
65
+ return String(value);
66
+ case 'bigint':
67
+ return JSON.stringify(`${value}n`);
68
+ case 'undefined':
69
+ case 'function':
70
+ case 'symbol':
71
+ return 'null';
72
+ default:
73
+ break;
74
+ }
75
+ // Ahead of the object branch, because none of the three has an own enumerable key: `Object.keys`
76
+ // is empty for all of them and the branch below would answer `{}` for every date, every map and
77
+ // every set alike.
78
+ if (value instanceof Date) return hashDate(value);
79
+ if (value instanceof Map || value instanceof Set) return hashCollection(value);
80
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`;
81
+ const record = value as Record<string, unknown>;
82
+ const keys = Object.keys(record)
83
+ .filter((key) => record[key] !== undefined)
84
+ .sort();
85
+ const entries = keys.map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`);
86
+ return `{${entries.join(',')}}`;
87
+ }
88
+
89
+ /**
90
+ * SHA-256, first 16 hex characters — the same primitive and width `@ultimat3/entity`'s `planScope`
91
+ * already chose, and for the same reason.
92
+ *
93
+ * This is a SHARING key over input a client chooses, not a checksum. It decides "same request,
94
+ * replay the stored response", which read-cache entry two callers are served from, which scope a
95
+ * cursor is bound to and which subscribers are served out of one live window — so a collision
96
+ * hands one caller another's rows. FNV-1a/32, which two of the three copies started as, is
97
+ * 4x10^9 values and brute-forceable offline in seconds: an input landing on another read's key was
98
+ * something an attacker could mint rather than something they had to wait for.
99
+ */
100
+ export function fingerprint(value: unknown): string {
101
+ return new Bun.CryptoHasher('sha256').update(canonicalJson(value)).digest('hex').slice(0, 16);
102
+ }
package/src/config.ts CHANGED
@@ -28,13 +28,28 @@ export interface ThemeConfig {
28
28
  */
29
29
  export interface AuthConfig {
30
30
  readonly signInPath: string | null;
31
- /** Where sign-in lands when there is nowhere to return to, or `?next=` is not same-origin. */
31
+ /**
32
+ * Where sign-in lands when there is nowhere to return to, or `?next=` is not same-origin.
33
+ *
34
+ * **Consulted by nothing, `As of 2026-08.`** Accepted, defaulted and merged here and read by no
35
+ * file in the repo — `dummy/social-media-clone/app.config.ts` sets `/dashboard` and gets
36
+ * whatever the sign-in route does on its own. Same shape `urlEnv`, `poolSize` and `schema` were
37
+ * deleted for below; this one is not deleted yet only because its writer is a tracked app's
38
+ * config, so removing the key and the line that sets it is one commit across two file sets.
39
+ */
32
40
  readonly afterSignInPath: string;
33
41
  }
34
42
 
35
43
  export interface PwaConfig {
36
44
  readonly enabled: boolean;
37
45
  readonly offline: OfflineStrategy;
46
+ /**
47
+ * **Consulted by nothing, `As of 2026-08.`** `wiki/Configuration.md` describes it as "render
48
+ * your own install affordance from the deferred event", both tracked apps set it, and
49
+ * `x new`'s scaffold writes it into every generated app — and no file reads it.
50
+ * `@ultimat3/pwa`'s `install.ts` is real and complete; nothing threads this flag into it.
51
+ * Delete the key or thread it; leaving it is a switch with no wire.
52
+ */
38
53
  readonly installPrompt: boolean;
39
54
  readonly backgroundSync: boolean;
40
55
  readonly push: boolean;
@@ -76,12 +91,18 @@ export interface JobsConfig {
76
91
  readonly visibilityTimeoutMs: number;
77
92
  }
78
93
 
94
+ /**
95
+ * No `heartbeatMs`. It was declared here, defaulted to 15_000, and read by NOTHING — deleted
96
+ * 2026-08-19. The socket beat is `new LiveClient({ heartbeatMs })`, browser code that cannot read
97
+ * server config, and the presence beat is DERIVED (`PresenceRegistry.heartbeatMs` is
98
+ * `max(1000, floor(ttlMs / 3))`). A second knob is a second number that can disagree with the one
99
+ * it is a fraction of, and a knob nothing reads is a knob nothing enforces — axioms 1 and 3.
100
+ */
79
101
  export interface RealtimeConfig {
80
102
  readonly enabled: boolean;
81
103
  readonly tier: RealtimeTier;
82
104
  readonly transport: RealtimeTransport;
83
105
  readonly urlEnv: string | undefined;
84
- readonly heartbeatMs: number;
85
106
  }
86
107
 
87
108
  export interface McpConfig {
@@ -91,7 +112,15 @@ export interface McpConfig {
91
112
 
92
113
  export interface AiConfig {
93
114
  readonly mcp: McpConfig;
94
- /** Env key for the model id, so no model string is baked into the image. */
115
+ /**
116
+ * Env key for the model id, so no model string is baked into the image — **an intention, not a
117
+ * behaviour, `As of 2026-08`.** The only read of it in the repo is the merge two hundred lines
118
+ * below, which copies it from input to output; nothing consumes the merged value, so
119
+ * `examples/dummy`'s `modelEnv: 'ANTHROPIC_MODEL'` selects no model. `@ultimat3/ai` reads env
120
+ * for API KEYS only (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`); the model is
121
+ * `request.model ?? DEFAULT_MODEL`, a compile-time constant in `models.ts`. So the exact thing
122
+ * this key exists to prevent — a model string baked into the image — is what actually happens.
123
+ */
95
124
  readonly modelEnv: string | undefined;
96
125
  }
97
126
 
@@ -210,13 +239,7 @@ function defaults(name: string): Omit<AppConfig, 'name'> {
210
239
  backoff: 'exponential',
211
240
  visibilityTimeoutMs: 30_000,
212
241
  },
213
- realtime: {
214
- enabled: false,
215
- tier: 'channels',
216
- transport: 'memory',
217
- urlEnv: undefined,
218
- heartbeatMs: 15_000,
219
- },
242
+ realtime: { enabled: false, tier: 'channels', transport: 'memory', urlEnv: undefined },
220
243
  ai: { mcp: { expose: true, path: '/mcp' }, modelEnv: undefined },
221
244
  };
222
245
  }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Two decimals compared EXACTLY, however long the digits run — the ordering Postgres gives a
3
+ * `numeric` or an `int8`, over the TEXT those columns' row values are.
4
+ *
5
+ * Tier 0 because the values are text and the fix has to be available wherever they arrive.
6
+ * `@ultimat3/entity`'s `bigint()` and `decimal()` both hand digits back as a string on purpose — a
7
+ * JS `bigint` is what `JSON.stringify` throws on and a `number` loses digits past 2^53, exactly
8
+ * where a legacy `int8` key lives — so no `typeof` branch catches them: `String(left) <
9
+ * String(right)` answered `["10","100","2","9"]` where the database answers `["2","9","10","100"]`,
10
+ * and a keyset page boundary was cut where the database never cuts one.
11
+ *
12
+ * It answers `undefined` rather than guessing, and that is the whole of its contract: a caller
13
+ * that knows the column's declared kind (`@ultimat3/entity`'s `compareByKind`) asks; a caller that
14
+ * does NOT know it — `@ultimat3/query`, whose `OrderKey` is a name and a direction — must not,
15
+ * because Postgres orders a `text` column holding `"10"` and `"9"` lexically and a comparator
16
+ * guessing "both sides look like decimals" would disagree with the SQL it printed.
17
+ */
18
+
19
+ /** A decimal, split so two of them can be compared exactly however long the digits run. */
20
+ interface Decimal {
21
+ readonly negative: boolean;
22
+ readonly whole: string;
23
+ readonly fraction: string;
24
+ }
25
+
26
+ const DECIMAL_SHAPE = /^([+-]?)(\d+)(?:\.(\d*))?$/;
27
+
28
+ /**
29
+ * The digits, or `undefined` for anything that is not a plain decimal — an exponent
30
+ * (`String(1e21)` is `"1e+21"`), a `NaN`, an empty string. Those are not values a `numeric` column
31
+ * can hold, so they are not values Postgres would be ordering either.
32
+ */
33
+ function decimalOf(value: unknown): Decimal | undefined {
34
+ const text =
35
+ typeof value === 'bigint' || typeof value === 'number'
36
+ ? String(value)
37
+ : typeof value === 'string'
38
+ ? value.trim()
39
+ : undefined;
40
+ const parts = text === undefined ? null : DECIMAL_SHAPE.exec(text);
41
+ const whole = parts?.[2];
42
+ if (parts === null || whole === undefined) return undefined;
43
+ return { negative: parts[1] === '-', whole, fraction: parts[3] ?? '' };
44
+ }
45
+
46
+ /**
47
+ * Exact at any width: the fractions are padded to one length and both sides become one integer, so
48
+ * a 38-digit `numeric` orders by its digits rather than by whatever a `Number` rounded it to.
49
+ */
50
+ function compare(left: Decimal, right: Decimal): number {
51
+ if (left.negative !== right.negative) return left.negative ? -1 : 1;
52
+ const width = Math.max(left.fraction.length, right.fraction.length);
53
+ const scaled = (value: Decimal): bigint =>
54
+ BigInt(`${value.whole}${value.fraction.padEnd(width, '0')}`);
55
+ const first = scaled(left);
56
+ const second = scaled(right);
57
+ // Never a subtraction: the difference between two `bigint`s is exact and the return type is a
58
+ // `number`, which cannot hold it.
59
+ const order = first < second ? -1 : first > second ? 1 : 0;
60
+ // Guarded rather than negated: `-0` is a different value from `0` to `Object.is` and to a caller
61
+ // writing `=== 0`, and two equal negatives are a tie.
62
+ return left.negative && order !== 0 ? -order : order;
63
+ }
64
+
65
+ /**
66
+ * `-1`, `0` or `1` for two plain decimals; `undefined` when EITHER side is not one.
67
+ *
68
+ * Both, or neither: one decimal against a value that is not one is not a numeric comparison, and
69
+ * answering for that pair would order a mixed column by a rule the database does not use.
70
+ */
71
+ export function compareDecimalText(left: unknown, right: unknown): number | undefined {
72
+ const first = decimalOf(left);
73
+ if (first === undefined) return undefined;
74
+ const second = decimalOf(right);
75
+ return second === undefined ? undefined : compare(first, second);
76
+ }
@@ -96,7 +96,15 @@ export interface EnvExampleReport {
96
96
  readonly ok: boolean;
97
97
  /** Declared in the schema, absent from the file. Always a defect. */
98
98
  readonly missing: readonly string[];
99
- /** In the file, not in the schema. Reported, never fatal — apps set keys nothing declares. */
99
+ /**
100
+ * In the file, not in the schema — never fatal, because apps set keys nothing declares.
101
+ *
102
+ * NOT reported on its own, and the comment here said it was. `ok` is `missing.length === 0`, so
103
+ * an example carrying only extra keys returns `ok: true` and `assertEnvExample` never builds an
104
+ * error: the list reaches a surface only as `meta` on a drift some MISSING key already raised.
105
+ * A caller that wants it reads `checkEnvExample(...).extra` itself, which is why this stays
106
+ * public. `env-example.test.ts` pins both halves.
107
+ */
100
108
  readonly extra: readonly string[];
101
109
  }
102
110
 
@@ -27,15 +27,29 @@ export const DEFAULT_ERROR_RETRY: ErrorRetry = 'terminal';
27
27
  * app whose clients stop retrying a rolling restart, which is the one case retrying always wins.
28
28
  * Only the exceptions are listed — everything else is `terminal` by the default above.
29
29
  */
30
- const CORE_ERROR_RETRY: Readonly<Record<string, ErrorRetry>> = Object.freeze({
31
- X_DRAINING: 'retryable',
32
- // A deadline that expired is the canonical back-off-and-try-again case: nothing about the
33
- // request was wrong, the budget ran out. Deliberately NOT `retry-after` — that spelling means
34
- // the responder named a time, and a timeout by definition produced no such answer. Its twin
35
- // `X_ABORTED` (the caller went away) is left to the `terminal` DEFAULT rather than listed here:
36
- // the answer is the same, and listing it would close a door nobody has asked to open.
37
- X_TIMEOUT: 'retryable',
38
- });
30
+ // A `Map`, not a frozen object: `code` is a caller's string on every read below, and
31
+ // `CORE_ERROR_RETRY['constructor']` on an object literal answers the `Object` FUNCTION — which
32
+ // `retryFor` then returned as an `ErrorRetry`, into every `UltimateError.retry` and `toJSON()`.
33
+ const CORE_ERROR_RETRY: ReadonlyMap<string, ErrorRetry> = new Map(
34
+ Object.entries({
35
+ X_DRAINING: 'retryable',
36
+ // A deadline that expired is the canonical back-off-and-try-again case: nothing about the
37
+ // request was wrong, the budget ran out. Deliberately NOT `retry-after` — that spelling means
38
+ // the responder named a time, and a timeout by definition produced no such answer. Its twin
39
+ // `X_ABORTED` (the caller went away) is left to the `terminal` DEFAULT rather than listed here:
40
+ // the answer is the same, and listing it would close a door nobody has asked to open.
41
+ X_TIMEOUT: 'retryable',
42
+ // Listed even though `terminal` is the default, and that is the whole point: `classifyThrown`
43
+ // reads an UNREGISTERED code carrying `terminal` as unclassified, because a per-instance
44
+ // `terminal` is indistinguishable from the default and honouring it would dead-letter the
45
+ // first attempt of every job in every app whose codes nobody has classified. So a stub that
46
+ // says "this build does not have the feature" fell through to the attempt count and burned a
47
+ // job's whole retry policy on a fact that cannot change between attempt 1 and attempt 5.
48
+ // It is core's code — every `notImplemented()` stub in the framework raises it — so it is
49
+ // classified once here rather than by each package that happens to throw it.
50
+ X_NOT_IMPLEMENTED: 'terminal',
51
+ } as const),
52
+ );
39
53
 
40
54
  const REGISTERED = new Map<string, ErrorRetry>();
41
55
 
@@ -70,7 +84,7 @@ export function registerErrorRetry(retries: Readonly<Record<string, ErrorRetry>>
70
84
  if (!isErrorRetry(retry)) {
71
85
  throw retryInvalid(code, `"${String(retry)}" is not ${ERROR_RETRY_KINDS.join(' | ')}`);
72
86
  }
73
- const core = CORE_ERROR_RETRY[code];
87
+ const core = CORE_ERROR_RETRY.get(code);
74
88
  if (core !== undefined) {
75
89
  throw retryInvalid(code, `the framework already classifies it as ${core}`);
76
90
  }
@@ -99,7 +113,7 @@ export function resetErrorRetry(): void {
99
113
  * Core table first, for the same belt-and-braces reason `retryFor` had it first.
100
114
  */
101
115
  export function declaredErrorRetry(code: string): ErrorRetry | undefined {
102
- return CORE_ERROR_RETRY[code] ?? REGISTERED.get(code);
116
+ return CORE_ERROR_RETRY.get(code) ?? REGISTERED.get(code);
103
117
  }
104
118
 
105
119
  export function retryFor(code: string): ErrorRetry {
@@ -42,7 +42,7 @@ export const imageDecodeFailed = (
42
42
  ): ImageDecodeFailedError =>
43
43
  new ImageDecodeFailedError(
44
44
  cause,
45
- 'check the file is a complete, uncorrupted image: `file <path>` then re-export it',
45
+ 're-export the image from its source: `file <path>` reports what these bytes actually are',
46
46
  meta,
47
47
  );
48
48
 
package/src/index.ts CHANGED
@@ -30,10 +30,9 @@ export {
30
30
  withFacts,
31
31
  } from './actor';
32
32
  export { APP_VERSION_KEY, appVersion, DEFAULT_APP_VERSION } from './app-version';
33
- export type { InvariantOptions } from './assert';
34
- export { assert, assertNever, invariant } from './assert';
35
- export type { Clock, FrozenClock } from './clock';
36
- export { frozenClock, systemClock } from './clock';
33
+ export { assert, assertNever, type InvariantOptions, invariant } from './assert';
34
+ export { canonicalJson, fingerprint } from './canonical-json';
35
+ export { type Clock, type FrozenClock, frozenClock, systemClock } from './clock';
37
36
  export type {
38
37
  AiConfig,
39
38
  AiConfigInput,
@@ -78,6 +77,7 @@ export {
78
77
  resetCursorSigning,
79
78
  usesDevCursorSecret,
80
79
  } from './cursor';
80
+ export { compareDecimalText } from './decimal-order';
81
81
  export type {
82
82
  Env,
83
83
  EnvBooleanVar,
@@ -425,8 +425,7 @@ export {
425
425
  MAX_IMAGE_PIXELS,
426
426
  rasterFrom,
427
427
  } from './image/raster';
428
- export type { ImageFit, ResizeSpec } from './image/resize';
429
- export { fitBox, resizeRaster, scaledToFit } from './image/resize';
428
+ export { fitBox, type ImageFit, type ResizeSpec, resizeRaster, scaledToFit } from './image/resize';
430
429
  export { impersonate, impersonationReason, isImpersonating } from './impersonate';
431
430
  export { cachedFormatter, canonicalLocale, MAX_CACHED_FORMATTERS } from './intl-cache';
432
431
  export type {
@@ -471,10 +470,8 @@ export {
471
470
  markListening,
472
471
  resetListeners,
473
472
  } from './listeners';
474
- export type { McpExposureDeclaration } from './mcp-exposure';
475
- export { isMcpExposed } from './mcp-exposure';
476
- export type { CappedBody } from './read-capped';
477
- export { readWithinLimit } from './read-capped';
473
+ export { isMcpExposed, type McpExposureDeclaration } from './mcp-exposure';
474
+ export { type CappedBody, readWithinLimit } from './read-capped';
478
475
  export type { ModuleRegistrar, PrimitiveKind, RegisteredPrimitive } from './registrar';
479
476
  export {
480
477
  hasPrimitiveRegistrar,
@@ -488,8 +485,7 @@ export { err, isErr, isOk, map, mapErr, ok, tryCatch, unwrap, unwrapOr } from '.
488
485
  export type { ResolveRoleOptions, Role, RoleInfo, ScalingSignal } from './roles';
489
486
  export { DEFAULT_ROLE, isRole, ROLE_INFO, ROLES, resolveRole } from './roles';
490
487
  export { safeUrl, URL_ATTRIBUTES } from './safe-url';
491
- export type { ServiceFactory } from './service';
492
- export { defineService, resetServices } from './service';
488
+ export { defineService, resetServices, type ServiceFactory } from './service';
493
489
  export { timingSafeEqual } from './timing-safe-equal';
494
490
  export {
495
491
  frameworkVersion,
package/src/logger.ts CHANGED
@@ -10,6 +10,9 @@ import { isSecret, REDACTED } from './secret';
10
10
  // it without importing the logger, and two constants spelled the same is one rename from a leak.
11
11
  export { REDACTED } from './secret';
12
12
 
13
+ /** What a `Date` this file cannot render says instead — the line survives, the value is named. */
14
+ const INVALID_DATE = 'an invalid Date';
15
+
13
16
  export const LOG_LEVELS = ['trace', 'debug', 'info', 'warn', 'error', 'fatal', 'silent'] as const;
14
17
 
15
18
  export type LogLevel = (typeof LOG_LEVELS)[number];
@@ -133,7 +136,7 @@ function serialise(value: unknown, depth: number): unknown {
133
136
  // `toISOString()` THROWS on an invalid Date, and an invalid Date is exactly the value worth
134
137
  // logging when a schedule went wrong.
135
138
  if (value instanceof Date) {
136
- return Number.isNaN(value.getTime()) ? 'an invalid Date' : value.toISOString();
139
+ return Number.isNaN(value.getTime()) ? INVALID_DATE : value.toISOString();
137
140
  }
138
141
  if (isUltimateError(value)) return value.toJSON();
139
142
  if (value instanceof Error) return { name: value.name, message: value.message };
@@ -198,6 +201,22 @@ function renderLine(
198
201
  }
199
202
  }
200
203
 
204
+ /**
205
+ * The one value in a line that is not the caller's, and it was the one read left unguarded:
206
+ * `toISOString()` raises `RangeError` on an invalid `Date`, and a `Clock` is injected — a frozen
207
+ * clock set from a bad string, or a clock whose `now()` throws, took the whole line with it. The
208
+ * same marker `serialise` gives an invalid `Date` in a FIELD, so one vocabulary covers both.
209
+ */
210
+ function timestamp(clock: Clock): string {
211
+ try {
212
+ const at = clock.now();
213
+ if (at instanceof Date && !Number.isNaN(at.getTime())) return at.toISOString();
214
+ } catch {
215
+ // A clock that fights being read is exactly the moment a line is worth keeping.
216
+ }
217
+ return INVALID_DATE;
218
+ }
219
+
201
220
  function envLevel(): LogLevel {
202
221
  const raw = process.env['LOG_LEVEL'];
203
222
  return raw !== undefined && (LOG_LEVELS as readonly string[]).includes(raw)
@@ -215,7 +234,7 @@ export function createLogger(options?: LoggerOptions): Logger {
215
234
  function emit(lineLevel: LogLevel, message: string, fields?: LogFields): void {
216
235
  if (LEVEL_WEIGHT[lineLevel] < threshold) return;
217
236
  const line = {
218
- ts: clock.now().toISOString(),
237
+ ts: timestamp(clock),
219
238
  level: lineLevel,
220
239
  msg: message,
221
240
  ...redactFields(bound),
@@ -0,0 +1,102 @@
1
+ // Single responsibility: the OpenTelemetry-shaped metric DATA MODEL — what a point, a
2
+ // descriptor, a collection and an instrument's options are. No registry and no state: `metrics.ts`
3
+ // owns those, and a reader (`metrics-text.ts`, an exporter) needs the shapes without them.
4
+
5
+ import type { SpanResource } from './telemetry';
6
+
7
+ export type MetricKind = 'counter' | 'gauge' | 'histogram';
8
+
9
+ /**
10
+ * Narrower than a span attribute on purpose: metric attributes become time-series labels, every
11
+ * distinct combination is a stored series, and an array label has no meaning in any exposition
12
+ * format. Keep the cardinality low — a user id here is an outage, and `maxSeries` is the ceiling
13
+ * that makes "keep it low" a mechanism instead of this sentence.
14
+ */
15
+ export type MetricAttributeValue = string | number | boolean;
16
+
17
+ export interface MetricAttributes {
18
+ readonly [key: string]: MetricAttributeValue;
19
+ }
20
+
21
+ export interface MetricDescriptor {
22
+ readonly name: string;
23
+ readonly kind: MetricKind;
24
+ /** UCUM, as OTel spells it: `1`, `s`, `By`, `{request}`. */
25
+ readonly unit: string;
26
+ readonly description: string;
27
+ }
28
+
29
+ export interface MetricPoint {
30
+ readonly attributes: MetricAttributes;
31
+ /** Counter: cumulative sum since process start. Gauge: last value. Histogram: sum. */
32
+ readonly value: number;
33
+ }
34
+
35
+ export interface HistogramPoint extends MetricPoint {
36
+ readonly count: number;
37
+ readonly min: number;
38
+ readonly max: number;
39
+ /** Explicit upper bounds; `buckets` is one longer, the last being the `+Inf` overflow. */
40
+ readonly bounds: readonly number[];
41
+ readonly buckets: readonly number[];
42
+ }
43
+
44
+ export interface ReadableMetric {
45
+ readonly descriptor: MetricDescriptor;
46
+ readonly points: readonly MetricPoint[];
47
+ }
48
+
49
+ export interface MetricCollection {
50
+ /** Epoch milliseconds, from the configured clock. */
51
+ readonly at: number;
52
+ readonly resource: SpanResource;
53
+ readonly metrics: readonly ReadableMetric[];
54
+ }
55
+
56
+ /** The driver seam. OTLP, Prometheus remote-write or a vendor SDK all arrive as one of these. */
57
+ export interface MetricExporter {
58
+ export(collection: MetricCollection): void;
59
+ }
60
+
61
+ export interface Counter {
62
+ add(value?: number, attributes?: MetricAttributes): void;
63
+ }
64
+
65
+ export interface Gauge {
66
+ /** Set the current value. */
67
+ record(value: number, attributes?: MetricAttributes): void;
68
+ /** Move the current value — `+1` on connect, `-1` on disconnect. */
69
+ add(delta: number, attributes?: MetricAttributes): void;
70
+ }
71
+
72
+ export interface Histogram {
73
+ record(value: number, attributes?: MetricAttributes): void;
74
+ }
75
+
76
+ export interface InstrumentOptions {
77
+ readonly unit?: string | undefined;
78
+ readonly description?: string | undefined;
79
+ /**
80
+ * Distinct label sets this instrument may store. Past it every new set folds into one overflow
81
+ * series. Defaults to `DEFAULT_MAX_SERIES`; the first declaration of a name wins.
82
+ */
83
+ readonly maxSeries?: number | undefined;
84
+ }
85
+
86
+ export interface GaugeOptions extends InstrumentOptions {
87
+ /**
88
+ * Async instrument: read at collection time instead of being pushed. Never stale.
89
+ * Stated twice for one name with two different callbacks is `X_METRIC_NAME_INVALID`, not a
90
+ * silent win for the first — see `assertSameDeclaration`.
91
+ */
92
+ readonly observe?: (() => number) | undefined;
93
+ }
94
+
95
+ export interface HistogramOptions extends InstrumentOptions {
96
+ /**
97
+ * Explicit bucket boundaries, ascending. Defaults to the OTel latency-in-seconds set.
98
+ * Stated twice for one name with two different sets is `X_METRIC_NAME_INVALID`, not a silent
99
+ * win for the first — see `assertSameDeclaration`.
100
+ */
101
+ readonly bounds?: readonly number[] | undefined;
102
+ }
package/src/metrics.ts CHANGED
@@ -3,9 +3,44 @@
3
3
  // always on, a no-op exporter by default, and the wire format supplied by a driver, never here.
4
4
 
5
5
  import { type Clock, systemClock } from './clock';
6
+ import { renderThrowable } from './error-render';
6
7
  import { type CodedErrorInit, UltimateError } from './errors';
7
8
  import { logger } from './logger';
8
- import { type SpanResource, serviceResource } from './telemetry';
9
+ import type {
10
+ Counter,
11
+ Gauge,
12
+ GaugeOptions,
13
+ Histogram,
14
+ HistogramOptions,
15
+ InstrumentOptions,
16
+ MetricAttributes,
17
+ MetricCollection,
18
+ MetricDescriptor,
19
+ MetricExporter,
20
+ MetricKind,
21
+ MetricPoint,
22
+ } from './metrics-types';
23
+ import { serviceResource } from './telemetry';
24
+
25
+ // The data model is a module of its own; the public surface is unchanged, so nothing that imports
26
+ // a metric type from here has to learn a second path.
27
+ export type {
28
+ Counter,
29
+ Gauge,
30
+ GaugeOptions,
31
+ Histogram,
32
+ HistogramOptions,
33
+ HistogramPoint,
34
+ InstrumentOptions,
35
+ MetricAttributes,
36
+ MetricAttributeValue,
37
+ MetricCollection,
38
+ MetricDescriptor,
39
+ MetricExporter,
40
+ MetricKind,
41
+ MetricPoint,
42
+ ReadableMetric,
43
+ } from './metrics-types';
9
44
 
10
45
  export class MetricNameInvalidError extends UltimateError {
11
46
  static readonly code = 'X_METRIC_NAME_INVALID';
@@ -31,103 +66,6 @@ export class MetricCardinalityError extends UltimateError {
31
66
  }
32
67
  }
33
68
 
34
- export type MetricKind = 'counter' | 'gauge' | 'histogram';
35
-
36
- /**
37
- * Narrower than a span attribute on purpose: metric attributes become time-series labels, every
38
- * distinct combination is a stored series, and an array label has no meaning in any exposition
39
- * format. Keep the cardinality low — a user id here is an outage, and `maxSeries` is the ceiling
40
- * that makes "keep it low" a mechanism instead of this sentence.
41
- */
42
- export type MetricAttributeValue = string | number | boolean;
43
-
44
- export interface MetricAttributes {
45
- readonly [key: string]: MetricAttributeValue;
46
- }
47
-
48
- export interface MetricDescriptor {
49
- readonly name: string;
50
- readonly kind: MetricKind;
51
- /** UCUM, as OTel spells it: `1`, `s`, `By`, `{request}`. */
52
- readonly unit: string;
53
- readonly description: string;
54
- }
55
-
56
- export interface MetricPoint {
57
- readonly attributes: MetricAttributes;
58
- /** Counter: cumulative sum since process start. Gauge: last value. Histogram: sum. */
59
- readonly value: number;
60
- }
61
-
62
- export interface HistogramPoint extends MetricPoint {
63
- readonly count: number;
64
- readonly min: number;
65
- readonly max: number;
66
- /** Explicit upper bounds; `buckets` is one longer, the last being the `+Inf` overflow. */
67
- readonly bounds: readonly number[];
68
- readonly buckets: readonly number[];
69
- }
70
-
71
- export interface ReadableMetric {
72
- readonly descriptor: MetricDescriptor;
73
- readonly points: readonly MetricPoint[];
74
- }
75
-
76
- export interface MetricCollection {
77
- /** Epoch milliseconds, from the configured clock. */
78
- readonly at: number;
79
- readonly resource: SpanResource;
80
- readonly metrics: readonly ReadableMetric[];
81
- }
82
-
83
- /** The driver seam. OTLP, Prometheus remote-write or a vendor SDK all arrive as one of these. */
84
- export interface MetricExporter {
85
- export(collection: MetricCollection): void;
86
- }
87
-
88
- export interface Counter {
89
- add(value?: number, attributes?: MetricAttributes): void;
90
- }
91
-
92
- export interface Gauge {
93
- /** Set the current value. */
94
- record(value: number, attributes?: MetricAttributes): void;
95
- /** Move the current value — `+1` on connect, `-1` on disconnect. */
96
- add(delta: number, attributes?: MetricAttributes): void;
97
- }
98
-
99
- export interface Histogram {
100
- record(value: number, attributes?: MetricAttributes): void;
101
- }
102
-
103
- export interface InstrumentOptions {
104
- readonly unit?: string | undefined;
105
- readonly description?: string | undefined;
106
- /**
107
- * Distinct label sets this instrument may store. Past it every new set folds into one overflow
108
- * series. Defaults to `DEFAULT_MAX_SERIES`; the first declaration of a name wins.
109
- */
110
- readonly maxSeries?: number | undefined;
111
- }
112
-
113
- export interface GaugeOptions extends InstrumentOptions {
114
- /**
115
- * Async instrument: read at collection time instead of being pushed. Never stale.
116
- * Stated twice for one name with two different callbacks is `X_METRIC_NAME_INVALID`, not a
117
- * silent win for the first — see `assertSameDeclaration`.
118
- */
119
- readonly observe?: (() => number) | undefined;
120
- }
121
-
122
- export interface HistogramOptions extends InstrumentOptions {
123
- /**
124
- * Explicit bucket boundaries, ascending. Defaults to the OTel latency-in-seconds set.
125
- * Stated twice for one name with two different sets is `X_METRIC_NAME_INVALID`, not a silent
126
- * win for the first — see `assertSameDeclaration`.
127
- */
128
- readonly bounds?: readonly number[] | undefined;
129
- }
130
-
131
69
  /**
132
70
  * The per-instrument series ceiling. 2000 is roomy for a bounded label set — every route pattern
133
71
  * times every status class times every method — and small enough that the process notices an
@@ -204,6 +142,8 @@ interface Instrument {
204
142
  readonly maxSeries: number;
205
143
  /** Reported once. A cardinality blow-up is one bug, not one log line per call. */
206
144
  overflowed: boolean;
145
+ /** Reported once, for the same reason: a scrape every 15s must not become a log every 15s. */
146
+ observeFailed: boolean;
207
147
  }
208
148
 
209
149
  const instruments = new Map<string, Instrument>();
@@ -230,6 +170,7 @@ export function resetMetrics(): void {
230
170
  for (const instrument of instruments.values()) {
231
171
  instrument.series.clear();
232
172
  instrument.overflowed = false;
173
+ instrument.observeFailed = false;
233
174
  }
234
175
  }
235
176
 
@@ -294,6 +235,7 @@ function declare(name: string, kind: MetricKind, options: GaugeOptions & Histogr
294
235
  observe: options.observe,
295
236
  maxSeries,
296
237
  overflowed: false,
238
+ observeFailed: false,
297
239
  };
298
240
  instruments.set(name, instrument);
299
241
  return instrument;
@@ -350,6 +292,26 @@ function reportOverflow(instrument: Instrument): void {
350
292
  logger.error(error.format(), { code: error.code, metric: name });
351
293
  }
352
294
 
295
+ /**
296
+ * Reported through the logger for `reportOverflow`'s reason and once for the same one — a scrape
297
+ * runs on a timer, so a permanently broken observer would otherwise write a log line every
298
+ * interval forever. A recurrence after the first is therefore silent by design; the missing series
299
+ * is the signal that outlives the line.
300
+ */
301
+ function reportObserveFailure(instrument: Instrument, thrown: unknown): void {
302
+ if (instrument.observeFailed) return;
303
+ instrument.observeFailed = true;
304
+ const { name, kind } = instrument.descriptor;
305
+ const error = new MetricValueInvalidError({
306
+ // `renderThrowable`, never `${thrown}`: the value is whatever the app's callback threw, and a
307
+ // `.message` read on it is the one that throws where there is nothing left to answer with.
308
+ cause: `the observe() callback of ${name} did not produce a value: ${renderThrowable(thrown)}; this instrument contributes no point until it does`,
309
+ fix: `make the observe() callback of ${name} total — return a finite number when the resource it reads is gone, e.g. ${kind}('${name}', { observe: () => pool?.size ?? 0 })`,
310
+ meta: { metric: name },
311
+ });
312
+ logger.error(error.format(), { code: error.code, metric: name });
313
+ }
314
+
353
315
  function createSeries(instrument: Instrument, key: string, attributes: MetricAttributes): Series {
354
316
  const created: Series = {
355
317
  attributes,
@@ -433,8 +395,19 @@ export function histogram(name: string, options?: HistogramOptions): Histogram {
433
395
  }
434
396
 
435
397
  function pointsOf(instrument: Instrument): readonly MetricPoint[] {
436
- if (instrument.observe !== undefined) {
437
- return [{ attributes: {}, value: instrument.observe() }];
398
+ const observe = instrument.observe;
399
+ if (observe !== undefined) {
400
+ try {
401
+ return [{ attributes: {}, value: finite(instrument.descriptor.name, observe()) }];
402
+ } catch (thrown) {
403
+ // The callback is the app's, run at SCRAPE time with no call site to blame: `() => pool.size`
404
+ // after a drain throws, and an unguarded read here took every other instrument down with it
405
+ // — /metrics 500s, `http_requests_total` goes invisible, and `startMetricExport`'s timer
406
+ // callback raises where nothing can catch it. One hostile observer costs its own point only,
407
+ // the same degradation `readinessChecks()` and the logger's per-key walk already make.
408
+ reportObserveFailure(instrument, thrown);
409
+ return [];
410
+ }
438
411
  }
439
412
  return [...instrument.series.values()].map((series) =>
440
413
  instrument.descriptor.kind === 'histogram'
@@ -1,6 +1,8 @@
1
1
  // Single responsibility: a `MetricExporter` that POSTs OTLP/HTTP JSON to a collector. No batching
2
2
  // — `collectMetrics()` already produces one whole snapshot per tick, so a tick is a request.
3
3
 
4
+ import { renderThrowable } from './error-render';
5
+ import { logger } from './logger';
4
6
  import type {
5
7
  HistogramPoint,
6
8
  MetricCollection,
@@ -124,10 +126,31 @@ export function otlpMetricExporter(options: OtlpMetricExporterOptions = {}): Otl
124
126
  return {
125
127
  export(collection: MetricCollection): void {
126
128
  startedAtMs ??= collection.at;
127
- const body = JSON.stringify(otlpMetricsRequest(collection, startedAtMs));
129
+ let body: string;
130
+ try {
131
+ // `export` is called from a timer, not awaited by anyone, so this throw had nowhere to go
132
+ // but into the metric loop that called it: `MetricAttributeValue` is a compile-time claim,
133
+ // and an attribute the app spelled as an object or a bigint reaches `otlpAttributes` as a
134
+ // TypeError. Dropped with a line, the same degradation `postOtlp` already applies to a
135
+ // collector that is down — telemetry is best-effort and must never end the process.
136
+ body = JSON.stringify(otlpMetricsRequest(collection, startedAtMs));
137
+ } catch (failure) {
138
+ logger.warn('otlp metric snapshot dropped', {
139
+ url,
140
+ metrics: collection.metrics.length,
141
+ error: renderThrowable(failure),
142
+ });
143
+ return;
144
+ }
128
145
  // Chained, so a slow collector cannot make two snapshots arrive out of order and turn a
129
- // cumulative counter into an apparent reset.
130
- inflight = inflight.then(() => postOtlp({ url, headers, body, timeoutMs, fetch: send }));
146
+ // cumulative counter into an apparent reset. Chained on a SETTLED shadow, for the reason
147
+ // `otlp-span-exporter.ts` spells out: a chain that carries a rejection forward stops calling
148
+ // `postOtlp` for the life of the process, in silence.
149
+ const settled = inflight.then(
150
+ () => undefined,
151
+ () => undefined,
152
+ );
153
+ inflight = settled.then(() => postOtlp({ url, headers, body, timeoutMs, fetch: send }));
131
154
  },
132
155
  flush(): Promise<void> {
133
156
  return inflight;
@@ -1,6 +1,8 @@
1
1
  // Single responsibility: a `SpanExporter` that POSTs OTLP/HTTP JSON to a collector. Batched,
2
2
  // because `SpanExporter.export` is one span and a request per span is a second load generator.
3
3
 
4
+ import { renderThrowable } from './error-render';
5
+ import { logger } from './logger';
4
6
  import {
5
7
  OTLP_SCOPE,
6
8
  type OtlpKeyValue,
@@ -134,15 +136,38 @@ export function otlpSpanExporter(options: OtlpSpanExporterOptions = {}): OtlpSpa
134
136
  const post = (batch: readonly ReadableSpan[]): Promise<void> => {
135
137
  const first = batch[0];
136
138
  if (first === undefined) return Promise.resolve();
137
- const body = JSON.stringify(otlpTraceRequest(batch, first.resource));
139
+ let body: string;
140
+ try {
141
+ // The one synchronous throw on this path, and the only way `inflight` can reject at all:
142
+ // `AttributeValue` is a compile-time claim, so an attribute the app spelled as an object, a
143
+ // bigint or a cycle reaches `anyValue`'s `value.map(...)` as a TypeError. Dropped with a
144
+ // line, the same degradation `postOtlp` already applies to a collector that is down —
145
+ // telemetry is best-effort and must never become the process's exit code.
146
+ body = JSON.stringify(otlpTraceRequest(batch, first.resource));
147
+ } catch (failure) {
148
+ logger.warn('otlp span batch dropped', {
149
+ url,
150
+ spans: batch.length,
151
+ error: renderThrowable(failure),
152
+ });
153
+ return Promise.resolve();
154
+ }
138
155
  return postOtlp({ url, headers, body, timeoutMs, fetch: send });
139
156
  };
140
157
 
141
158
  const drainQueue = (): Promise<void> => {
142
159
  const batch = queue.splice(0, queue.length);
143
160
  // Chained, not concurrent: a collector reordering batches from one process turns a parent's
144
- // span arriving after its child into a broken trace on the read side.
145
- inflight = inflight.then(() => post(batch));
161
+ // span arriving after its child into a broken trace on the read side. Chained on a SETTLED
162
+ // shadow, because a chain that carries a rejection forward is poisoned for the life of the
163
+ // process: `post` is never called again while the queue keeps emptying, so every later span is
164
+ // dropped in silence and every timer tick mints a fresh unhandled rejection — which Bun ends
165
+ // the process on. Same shape as `offline-queue.ts`'s drain chain.
166
+ const settled = inflight.then(
167
+ () => undefined,
168
+ () => undefined,
169
+ );
170
+ inflight = settled.then(() => post(batch));
146
171
  return inflight;
147
172
  };
148
173
 
package/src/type-pins.ts CHANGED
@@ -52,9 +52,15 @@ type _ActorFactMapAcceptsNothing = Assert<
52
52
  >;
53
53
 
54
54
  /**
55
- * The seam is additive: an actor literal written before it existed still is one. `facts` is
56
- * optional for that reason and not only for the denial rule — a required member would have been
57
- * a breaking change to a tier-0 type every package depends on.
55
+ * The seam is additive: an actor literal carrying the required members but no `facts` still is an
56
+ * `Actor`. `facts` is optional for that reason and not only for the denial rule — making it
57
+ * required would be a breaking change to a tier-0 type every package depends on.
58
+ *
59
+ * `permissions` joined the required set in 4.0.0 and is spelled here deliberately. That WAS such a
60
+ * breaking change, made knowingly and with a migration: policy's `PolicyActorFields` held it, so
61
+ * `userActor({ permissions })` silently dropped it and no builder could spell a direct grant. This
62
+ * pin is what makes the next one impossible to add by accident — a new required member fails here
63
+ * before it fails in an app.
58
64
  */
59
65
  type _ActorWithoutFactsIsStillAnActor = Assert<
60
66
  [
@@ -63,6 +69,7 @@ type _ActorWithoutFactsIsStillAnActor = Assert<
63
69
  readonly id: string;
64
70
  readonly roles: readonly string[];
65
71
  readonly scopes: readonly string[];
72
+ readonly permissions: readonly string[];
66
73
  },
67
74
  ] extends [Actor]
68
75
  ? true