@nifrajs/core 1.11.0 → 1.12.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.
@@ -0,0 +1,550 @@
1
+ /**
2
+ * Framework-neutral execution causality.
3
+ *
4
+ * Traces describe one synchronous/remote observation tree. Causality survives the durable seams a
5
+ * trace cannot: a command commits an outbox event, a workflow resumes tomorrow, a projection
6
+ * consumes that event, and reconciliation repairs drift. This module carries only bounded identity
7
+ * tokens and links; payloads, tenant identifiers, request URLs, and arbitrary attributes cannot enter
8
+ * the graph by construction.
9
+ */
10
+
11
+ /** A node category such as `request`, `command`, `event`, `workflow`, `projection`, or `repair`. */
12
+ export type CausalityKind = string
13
+
14
+ /** A bounded identity within one execution graph. */
15
+ export interface CausalityRef {
16
+ readonly kind: CausalityKind
17
+ readonly id: string
18
+ }
19
+
20
+ /** Optional OpenTelemetry anchor for the nearest observed ancestor. */
21
+ export interface CausalityTrace {
22
+ readonly traceId: string
23
+ readonly spanId: string
24
+ }
25
+
26
+ /** The propagation shape carried across commands/events/jobs. */
27
+ export interface CausalityContext {
28
+ readonly executionId: string
29
+ readonly current: CausalityRef
30
+ /** Nearest observed ancestor; downstream observations can attach a real OTel span link to it. */
31
+ readonly trace?: CausalityTrace
32
+ }
33
+
34
+ /** One immediate parent edge. Relation is a bounded token (`caused`, `emitted`, `projected`, …). */
35
+ export interface CausalityParent extends CausalityRef {
36
+ readonly relation: string
37
+ }
38
+
39
+ /** One append-only graph record. It intentionally has no payload or metadata field. */
40
+ export interface CausalityRecord {
41
+ readonly executionId: string
42
+ readonly node: CausalityRef & {
43
+ readonly at: number
44
+ /** Present only when this node itself opened an observation. */
45
+ readonly trace?: CausalityTrace
46
+ }
47
+ readonly parents: readonly CausalityParent[]
48
+ }
49
+
50
+ /** A propagation context plus the graph record a durable adapter should append. */
51
+ export interface CausalityStep {
52
+ readonly context: CausalityContext
53
+ readonly record: CausalityRecord
54
+ }
55
+
56
+ export interface CausalityRecorder<Tx = unknown> {
57
+ /** Idempotently append one node and its immediate incoming edges. */
58
+ record(record: CausalityRecord, tx?: Tx): Promise<"inserted" | "duplicate">
59
+ }
60
+
61
+ export interface CausalityTimelineItem {
62
+ readonly cursor: string
63
+ readonly record: CausalityRecord
64
+ }
65
+
66
+ export interface CausalityTimelinePage {
67
+ readonly items: readonly CausalityTimelineItem[]
68
+ readonly nextCursor?: string
69
+ }
70
+
71
+ export interface CausalityReader {
72
+ timeline(
73
+ executionId: string,
74
+ options?: { readonly after?: string; readonly limit?: number },
75
+ ): Promise<CausalityTimelinePage>
76
+ }
77
+
78
+ export type CausalityGraphStore<Tx = unknown> = CausalityRecorder<Tx> & CausalityReader
79
+
80
+ export class CausalityConflictError extends Error {
81
+ constructor(
82
+ readonly executionId: string,
83
+ readonly node: CausalityRef,
84
+ ) {
85
+ super(`causality conflict: ${node.kind}:${node.id} changed inside execution ${executionId}`)
86
+ this.name = "CausalityConflictError"
87
+ }
88
+ }
89
+
90
+ export class CausalityCapacityError extends Error {
91
+ constructor(readonly maxRecords: number) {
92
+ super(`causality store capacity exceeded (${maxRecords} records)`)
93
+ this.name = "CausalityCapacityError"
94
+ }
95
+ }
96
+
97
+ export interface StartCausalityOptions {
98
+ /** Stable graph identity. Generate once at the ingress boundary, then propagate it. */
99
+ readonly executionId: string
100
+ /** Epoch milliseconds. Injectable for deterministic tests. Default `Date.now()`. */
101
+ readonly at?: number
102
+ /** Observation opened for this exact node. */
103
+ readonly trace?: CausalityTrace
104
+ }
105
+
106
+ export interface ContinueCausalityOptions {
107
+ /** Edge relation. Default `caused`. */
108
+ readonly relation?: string
109
+ /** Epoch milliseconds. Injectable for deterministic tests. Default `Date.now()`. */
110
+ readonly at?: number
111
+ /** A new observation opened for this exact node; otherwise the nearest anchor is propagated. */
112
+ readonly trace?: CausalityTrace
113
+ }
114
+
115
+ export type CausalityParseResult =
116
+ | { readonly success: true; readonly context: CausalityContext }
117
+ | {
118
+ readonly success: false
119
+ readonly reason: "missing" | "incomplete" | "invalid" | "unknown-field"
120
+ }
121
+
122
+ export type CausalityRecordParseResult =
123
+ | { readonly success: true; readonly record: CausalityRecord }
124
+ | {
125
+ readonly success: false
126
+ readonly reason: "incomplete" | "invalid" | "unknown-field"
127
+ }
128
+
129
+ export const CAUSALITY_EXECUTION_HEADER = "x-nifra-execution-id"
130
+ export const CAUSALITY_KIND_HEADER = "x-nifra-causality-kind"
131
+ export const CAUSALITY_NODE_HEADER = "x-nifra-causality-id"
132
+ export const CAUSALITY_TRACE_HEADER = "x-nifra-causality-trace"
133
+
134
+ const IDENTITY = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/
135
+ const KIND = /^[a-z][a-z0-9._-]{0,63}$/
136
+ const RELATION = /^[a-z][a-z0-9._-]{0,63}$/
137
+ const TRACE_ID = /^[0-9a-f]{32}$/
138
+ const SPAN_ID = /^[0-9a-f]{16}$/
139
+ const MAX_PARENTS = 16
140
+ const DEFAULT_TIMELINE_LIMIT = 100
141
+ const MAX_TIMELINE_LIMIT = 500
142
+
143
+ function identity(value: string, label: string): string {
144
+ if (!IDENTITY.test(value)) {
145
+ throw new TypeError(`causality: ${label} must be a bounded identity token`)
146
+ }
147
+ return value
148
+ }
149
+
150
+ function kind(value: string): string {
151
+ if (!KIND.test(value)) throw new TypeError("causality: kind must be a bounded lowercase token")
152
+ return value
153
+ }
154
+
155
+ function relation(value: string): string {
156
+ if (!RELATION.test(value)) {
157
+ throw new TypeError("causality: relation must be a bounded lowercase token")
158
+ }
159
+ return value
160
+ }
161
+
162
+ function timestamp(value: number | undefined): number {
163
+ const at = value ?? Date.now()
164
+ if (!Number.isSafeInteger(at) || at < 0) {
165
+ throw new TypeError("causality: at must be a non-negative safe-integer epoch millisecond")
166
+ }
167
+ return at
168
+ }
169
+
170
+ function trace(value: CausalityTrace | undefined): CausalityTrace | undefined {
171
+ if (value === undefined) return undefined
172
+ if (!TRACE_ID.test(value.traceId) || !SPAN_ID.test(value.spanId)) {
173
+ throw new TypeError("causality: trace must contain lowercase W3C trace/span ids")
174
+ }
175
+ return Object.freeze({ traceId: value.traceId, spanId: value.spanId })
176
+ }
177
+
178
+ function ref(nodeKind: string, id: string): CausalityRef {
179
+ return Object.freeze({ kind: kind(nodeKind), id: identity(id, "node id") })
180
+ }
181
+
182
+ function recordOf(value: unknown): Readonly<Record<string, unknown>> | undefined {
183
+ return value !== null && typeof value === "object" && !Array.isArray(value)
184
+ ? (value as Readonly<Record<string, unknown>>)
185
+ : undefined
186
+ }
187
+
188
+ function onlyKeys(value: Readonly<Record<string, unknown>>, allowed: ReadonlySet<string>): boolean {
189
+ return Object.keys(value).every((key) => allowed.has(key))
190
+ }
191
+
192
+ function buildStep(
193
+ executionId: string,
194
+ current: CausalityRef,
195
+ parents: readonly CausalityParent[],
196
+ at: number,
197
+ nodeTrace: CausalityTrace | undefined,
198
+ propagatedTrace: CausalityTrace | undefined,
199
+ ): CausalityStep {
200
+ const node = Object.freeze({
201
+ ...current,
202
+ at,
203
+ ...(nodeTrace === undefined ? {} : { trace: nodeTrace }),
204
+ })
205
+ const frozenParents = Object.freeze([...parents])
206
+ const context = Object.freeze({
207
+ executionId,
208
+ current,
209
+ ...(propagatedTrace === undefined ? {} : { trace: propagatedTrace }),
210
+ })
211
+ const record = Object.freeze({ executionId, node, parents: frozenParents })
212
+ return Object.freeze({ context, record })
213
+ }
214
+
215
+ function normalizeRecord(input: CausalityRecord): CausalityRecord {
216
+ const executionId = identity(input.executionId, "executionId")
217
+ if (input.parents.length > MAX_PARENTS) {
218
+ throw new RangeError(`causality: a record cannot have more than ${MAX_PARENTS} parents`)
219
+ }
220
+ const current = ref(input.node.kind, input.node.id)
221
+ const nodeTrace = trace(input.node.trace)
222
+ const parents = input.parents.map((parent) =>
223
+ Object.freeze({
224
+ ...ref(parent.kind, parent.id),
225
+ relation: relation(parent.relation),
226
+ }),
227
+ )
228
+ return Object.freeze({
229
+ executionId,
230
+ node: Object.freeze({
231
+ ...current,
232
+ at: timestamp(input.node.at),
233
+ ...(nodeTrace === undefined ? {} : { trace: nodeTrace }),
234
+ }),
235
+ parents: Object.freeze(parents),
236
+ })
237
+ }
238
+
239
+ /** Start a root execution node at an ingress boundary. */
240
+ export function startCausality(
241
+ nodeKind: CausalityKind,
242
+ id: string,
243
+ options: StartCausalityOptions,
244
+ ): CausalityStep {
245
+ const executionId = identity(options.executionId, "executionId")
246
+ const current = ref(nodeKind, id)
247
+ const nodeTrace = trace(options.trace)
248
+ return buildStep(executionId, current, [], timestamp(options.at), nodeTrace, nodeTrace)
249
+ }
250
+
251
+ /** Continue one execution from a single immediate parent. */
252
+ export function continueCausality(
253
+ parent: CausalityContext,
254
+ nodeKind: CausalityKind,
255
+ id: string,
256
+ options: ContinueCausalityOptions = {},
257
+ ): CausalityStep {
258
+ return joinCausality([parent], nodeKind, id, options)
259
+ }
260
+
261
+ /** Join several immediate parents. Cross-execution joins fail closed. */
262
+ export function joinCausality(
263
+ parents: readonly CausalityContext[],
264
+ nodeKind: CausalityKind,
265
+ id: string,
266
+ options: ContinueCausalityOptions = {},
267
+ ): CausalityStep {
268
+ if (parents.length === 0 || parents.length > MAX_PARENTS) {
269
+ throw new RangeError(`causality: parents must contain 1 to ${MAX_PARENTS} contexts`)
270
+ }
271
+ const executionId = identity(parents[0]?.executionId ?? "", "executionId")
272
+ if (parents.some((parent) => parent.executionId !== executionId)) {
273
+ throw new Error("causality: joined parents must belong to the same execution")
274
+ }
275
+ const edgeRelation = relation(options.relation ?? "caused")
276
+ const seen = new Set<string>()
277
+ const incoming: CausalityParent[] = []
278
+ for (const parent of parents) {
279
+ const parentRef = ref(parent.current.kind, parent.current.id)
280
+ const key = `${parentRef.kind}\n${parentRef.id}`
281
+ if (seen.has(key)) continue
282
+ seen.add(key)
283
+ incoming.push(Object.freeze({ ...parentRef, relation: edgeRelation }))
284
+ }
285
+ const current = ref(nodeKind, id)
286
+ const nodeTrace = trace(options.trace)
287
+ const propagatedTrace = nodeTrace ?? parents.find((parent) => parent.trace !== undefined)?.trace
288
+ return buildStep(
289
+ executionId,
290
+ current,
291
+ incoming,
292
+ timestamp(options.at),
293
+ nodeTrace,
294
+ propagatedTrace,
295
+ )
296
+ }
297
+
298
+ const CONTEXT_FIELDS: ReadonlySet<string> = new Set(["executionId", "current", "trace"])
299
+ const REF_FIELDS: ReadonlySet<string> = new Set(["kind", "id"])
300
+ const TRACE_FIELDS: ReadonlySet<string> = new Set(["traceId", "spanId"])
301
+ const RECORD_FIELDS: ReadonlySet<string> = new Set(["executionId", "node", "parents"])
302
+ const NODE_FIELDS: ReadonlySet<string> = new Set(["kind", "id", "at", "trace"])
303
+ const PARENT_FIELDS: ReadonlySet<string> = new Set(["kind", "id", "relation"])
304
+
305
+ /** Parse an untrusted durable graph record. Unknown fields fail closed at every nesting level. */
306
+ export function parseCausalityRecord(input: unknown): CausalityRecordParseResult {
307
+ const value = recordOf(input)
308
+ if (value === undefined) return { success: false, reason: "invalid" }
309
+ if (!onlyKeys(value, RECORD_FIELDS)) return { success: false, reason: "unknown-field" }
310
+ const node = recordOf(value.node)
311
+ if (node === undefined || !Array.isArray(value.parents)) {
312
+ return { success: false, reason: "incomplete" }
313
+ }
314
+ if (!onlyKeys(node, NODE_FIELDS)) return { success: false, reason: "unknown-field" }
315
+ const nodeTrace =
316
+ value.node !== undefined && node.trace !== undefined ? recordOf(node.trace) : undefined
317
+ if (node.trace !== undefined && nodeTrace === undefined) {
318
+ return { success: false, reason: "invalid" }
319
+ }
320
+ if (nodeTrace !== undefined && !onlyKeys(nodeTrace, TRACE_FIELDS)) {
321
+ return { success: false, reason: "unknown-field" }
322
+ }
323
+ const parents: CausalityParent[] = []
324
+ for (const rawParent of value.parents) {
325
+ const parent = recordOf(rawParent)
326
+ if (parent === undefined) return { success: false, reason: "invalid" }
327
+ if (!onlyKeys(parent, PARENT_FIELDS)) return { success: false, reason: "unknown-field" }
328
+ if (
329
+ typeof parent.kind !== "string" ||
330
+ typeof parent.id !== "string" ||
331
+ typeof parent.relation !== "string"
332
+ ) {
333
+ return { success: false, reason: "incomplete" }
334
+ }
335
+ parents.push({ kind: parent.kind, id: parent.id, relation: parent.relation })
336
+ }
337
+ if (
338
+ typeof value.executionId !== "string" ||
339
+ typeof node.kind !== "string" ||
340
+ typeof node.id !== "string" ||
341
+ typeof node.at !== "number" ||
342
+ (nodeTrace !== undefined &&
343
+ (typeof nodeTrace.traceId !== "string" || typeof nodeTrace.spanId !== "string"))
344
+ ) {
345
+ return { success: false, reason: "incomplete" }
346
+ }
347
+ try {
348
+ return {
349
+ success: true,
350
+ record: normalizeRecord({
351
+ executionId: value.executionId,
352
+ node: {
353
+ kind: node.kind,
354
+ id: node.id,
355
+ at: node.at,
356
+ ...(nodeTrace === undefined
357
+ ? {}
358
+ : {
359
+ trace: { traceId: nodeTrace.traceId as string, spanId: nodeTrace.spanId as string },
360
+ }),
361
+ },
362
+ parents,
363
+ }),
364
+ }
365
+ } catch {
366
+ return { success: false, reason: "invalid" }
367
+ }
368
+ }
369
+
370
+ /** Parse an untrusted JSON causality context. Unknown fields fail closed so payloads cannot hitchhike. */
371
+ export function parseCausalityContext(input: unknown): CausalityParseResult {
372
+ const value = recordOf(input)
373
+ if (value === undefined) return { success: false, reason: "invalid" }
374
+ if (!onlyKeys(value, CONTEXT_FIELDS)) return { success: false, reason: "unknown-field" }
375
+ const current = recordOf(value.current)
376
+ if (current === undefined) return { success: false, reason: "incomplete" }
377
+ if (!onlyKeys(current, REF_FIELDS)) return { success: false, reason: "unknown-field" }
378
+ const rawTrace = value.trace === undefined ? undefined : recordOf(value.trace)
379
+ if (value.trace !== undefined && rawTrace === undefined) {
380
+ return { success: false, reason: "invalid" }
381
+ }
382
+ if (rawTrace !== undefined && !onlyKeys(rawTrace, TRACE_FIELDS)) {
383
+ return { success: false, reason: "unknown-field" }
384
+ }
385
+ if (
386
+ typeof value.executionId !== "string" ||
387
+ typeof current.kind !== "string" ||
388
+ typeof current.id !== "string" ||
389
+ (rawTrace !== undefined &&
390
+ (typeof rawTrace.traceId !== "string" || typeof rawTrace.spanId !== "string"))
391
+ ) {
392
+ return { success: false, reason: "incomplete" }
393
+ }
394
+ try {
395
+ const context = Object.freeze({
396
+ executionId: identity(value.executionId, "executionId"),
397
+ current: ref(current.kind, current.id),
398
+ ...(rawTrace === undefined
399
+ ? {}
400
+ : {
401
+ trace: trace({
402
+ traceId: rawTrace.traceId as string,
403
+ spanId: rawTrace.spanId as string,
404
+ }) as CausalityTrace,
405
+ }),
406
+ })
407
+ return { success: true, context }
408
+ } catch {
409
+ return { success: false, reason: "invalid" }
410
+ }
411
+ }
412
+
413
+ /** Serialize the propagation context into bounded HTTP headers. */
414
+ export function causalityHeaders(context: CausalityContext): Readonly<Record<string, string>> {
415
+ const parsed = parseCausalityContext(context)
416
+ if (!parsed.success) throw new TypeError(`causality: invalid context (${parsed.reason})`)
417
+ return Object.freeze({
418
+ [CAUSALITY_EXECUTION_HEADER]: parsed.context.executionId,
419
+ [CAUSALITY_KIND_HEADER]: parsed.context.current.kind,
420
+ [CAUSALITY_NODE_HEADER]: parsed.context.current.id,
421
+ ...(parsed.context.trace === undefined
422
+ ? {}
423
+ : {
424
+ [CAUSALITY_TRACE_HEADER]: `${parsed.context.trace.traceId}-${parsed.context.trace.spanId}`,
425
+ }),
426
+ })
427
+ }
428
+
429
+ /** Parse the public header convention without ever throwing on hostile input. */
430
+ export function readCausalityHeaders(headers: Headers): CausalityParseResult {
431
+ const executionId = headers.get(CAUSALITY_EXECUTION_HEADER)
432
+ const nodeKind = headers.get(CAUSALITY_KIND_HEADER)
433
+ const nodeId = headers.get(CAUSALITY_NODE_HEADER)
434
+ const encodedTrace = headers.get(CAUSALITY_TRACE_HEADER)
435
+ if (executionId === null && nodeKind === null && nodeId === null && encodedTrace === null) {
436
+ return { success: false, reason: "missing" }
437
+ }
438
+ if (executionId === null || nodeKind === null || nodeId === null) {
439
+ return { success: false, reason: "incomplete" }
440
+ }
441
+ let decodedTrace: CausalityTrace | undefined
442
+ if (encodedTrace !== null) {
443
+ const match = /^([0-9a-f]{32})-([0-9a-f]{16})$/.exec(encodedTrace)
444
+ if (match === null) return { success: false, reason: "invalid" }
445
+ decodedTrace = { traceId: match[1] as string, spanId: match[2] as string }
446
+ }
447
+ return parseCausalityContext({
448
+ executionId,
449
+ current: { kind: nodeKind, id: nodeId },
450
+ ...(decodedTrace === undefined ? {} : { trace: decodedTrace }),
451
+ })
452
+ }
453
+
454
+ interface MemoryEntry {
455
+ readonly sequence: number
456
+ readonly canonical: string
457
+ readonly record: CausalityRecord
458
+ }
459
+
460
+ export interface MemoryCausalityStoreOptions {
461
+ /** Global record bound. Default 10,000. */
462
+ readonly maxRecords?: number
463
+ /** In-memory evidence disappears on restart and is rejected in production unless explicitly allowed. */
464
+ readonly allowInProduction?: boolean
465
+ }
466
+
467
+ const timelineCursor = (entry: MemoryEntry): string =>
468
+ `${entry.record.node.at.toString(36)}:${entry.sequence.toString(36)}`
469
+
470
+ function timelineLimit(value: number | undefined): number {
471
+ const limit = value ?? DEFAULT_TIMELINE_LIMIT
472
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_TIMELINE_LIMIT) {
473
+ throw new RangeError(
474
+ `causality: timeline limit must be an integer from 1 to ${MAX_TIMELINE_LIMIT}`,
475
+ )
476
+ }
477
+ return limit
478
+ }
479
+
480
+ /** Bounded dev/test graph store. Production callers should provide a durable adapter. */
481
+ export function createMemoryCausalityStore(
482
+ options: MemoryCausalityStoreOptions = {},
483
+ ): CausalityGraphStore {
484
+ if (
485
+ typeof process !== "undefined" &&
486
+ process.env.NODE_ENV === "production" &&
487
+ !options.allowInProduction
488
+ ) {
489
+ throw new Error(
490
+ "causality: the memory store is not durable; configure a production graph adapter",
491
+ )
492
+ }
493
+ const maxRecords = options.maxRecords ?? 10_000
494
+ if (!Number.isSafeInteger(maxRecords) || maxRecords < 1) {
495
+ throw new RangeError("causality: maxRecords must be a positive safe integer")
496
+ }
497
+ const byNode = new Map<string, MemoryEntry>()
498
+ const byExecution = new Map<string, MemoryEntry[]>()
499
+ let sequence = 0
500
+
501
+ return {
502
+ async record(input) {
503
+ const parsed = parseCausalityRecord(input)
504
+ if (!parsed.success) throw new TypeError(`causality: invalid record (${parsed.reason})`)
505
+ const record = parsed.record
506
+ const key = `${record.executionId}\n${record.node.kind}\n${record.node.id}`
507
+ const canonical = JSON.stringify(record)
508
+ const prior = byNode.get(key)
509
+ if (prior !== undefined) {
510
+ if (prior.canonical !== canonical) {
511
+ throw new CausalityConflictError(record.executionId, record.node)
512
+ }
513
+ return "duplicate"
514
+ }
515
+ if (byNode.size >= maxRecords) throw new CausalityCapacityError(maxRecords)
516
+ const entry = Object.freeze({ sequence: sequence++, canonical, record })
517
+ byNode.set(key, entry)
518
+ const execution = byExecution.get(record.executionId) ?? []
519
+ execution.push(entry)
520
+ byExecution.set(record.executionId, execution)
521
+ return "inserted"
522
+ },
523
+ async timeline(rawExecutionId, query = {}) {
524
+ const executionId = identity(rawExecutionId, "executionId")
525
+ const limit = timelineLimit(query.limit)
526
+ const entries = [...(byExecution.get(executionId) ?? [])].sort(
527
+ (left, right) =>
528
+ left.record.node.at - right.record.node.at || left.sequence - right.sequence,
529
+ )
530
+ let start = 0
531
+ if (query.after !== undefined) {
532
+ const index = entries.findIndex((entry) => timelineCursor(entry) === query.after)
533
+ if (index < 0) throw new TypeError("causality: invalid timeline cursor")
534
+ start = index + 1
535
+ }
536
+ const selected = entries.slice(start, start + limit)
537
+ const hasMore = start + selected.length < entries.length
538
+ return Object.freeze({
539
+ items: Object.freeze(
540
+ selected.map((entry) =>
541
+ Object.freeze({ cursor: timelineCursor(entry), record: entry.record }),
542
+ ),
543
+ ),
544
+ ...(hasMore && selected.length > 0
545
+ ? { nextCursor: timelineCursor(selected[selected.length - 1] as MemoryEntry) }
546
+ : {}),
547
+ })
548
+ },
549
+ }
550
+ }
package/src/index.ts CHANGED
@@ -12,12 +12,13 @@
12
12
  * (it shipped at "0.0.0" through 1.0.0 before those guards existed). Kept narrow (`as const`) so
13
13
  * consumers can pin it at the type level — see `test/version.test-d.ts`.
14
14
  */
15
- export const VERSION = "1.11.0" as const
15
+ export const VERSION = "1.12.0" as const
16
16
 
17
17
  export type Version = typeof VERSION
18
18
 
19
19
  export * from "./assurance.ts"
20
20
  export * from "./capabilities.ts"
21
+ export * from "./causality.ts"
21
22
  export * from "./classification.ts"
22
23
  export * from "./diff.ts"
23
24
  export { FrameworkError, RouteConfigError, type RouteConfigErrorCode } from "./errors.ts"
package/src/invariants.ts CHANGED
@@ -334,6 +334,10 @@ interface RoutePlan {
334
334
  const FUZZABLE_METHODS = new Set(["GET", "POST", "PUT", "PATCH", "DELETE"])
335
335
 
336
336
  /** Run the contract-derived invariant suite against a reflectable app. Pure; no ambient state. */
337
+ /**
338
+ * @deprecated Use `runAdversarialContract` from `@nifrajs/testing`. The testing package is the
339
+ * authoritative deep contract laboratory and powers CLI L4; this export remains for compatibility.
340
+ */
337
341
  export async function runContractInvariants(
338
342
  app: ReflectableApp,
339
343
  options: RunInvariantsOptions = {},
@@ -3793,8 +3793,16 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3793
3793
  * `app.listen(PORT, { reusePort: true })`; see `examples/cluster.ts`. Every process must opt in,
3794
3794
  * and all of them must be the same app. Linux balances ~evenly; macOS accepts the flag but may
3795
3795
  * favor one socket (fine for dev, measure on Linux for production numbers).
3796
+ *
3797
+ * `hostname` is the bind address, defaulting to Bun's `0.0.0.0` (every interface). Pass
3798
+ * `"127.0.0.1"` to bind loopback only - an admin surface, a sidecar, or anything that must not be
3799
+ * reachable off the box. Omitting it when you meant to restrict is a real exposure, so it is a
3800
+ * first-class option rather than something a caller has to drop down to `Bun.serve` for.
3796
3801
  */
3797
- listen(port: number, options?: { readonly reusePort?: boolean }): RunningServer {
3802
+ listen(
3803
+ port: number,
3804
+ options?: { readonly reusePort?: boolean; readonly hostname?: string },
3805
+ ): RunningServer {
3798
3806
  if (typeof Bun === "undefined") {
3799
3807
  // listen() is the one Bun-specific seam. Off Bun, fail loud + actionable rather
3800
3808
  // than letting the Bun.serve call below throw a bare `ReferenceError: Bun is not
@@ -3820,17 +3828,22 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3820
3828
  ? undefined
3821
3829
  : (getWsRuntime() as WsRuntime).bunHandlers(this.topics as TopicRegistry)
3822
3830
  const reusePort = options?.reusePort === true
3831
+ // Spread rather than pass `hostname: undefined` - Bun treats an explicit undefined as a value
3832
+ // on some option paths, and omitting is what selects its 0.0.0.0 default.
3833
+ const bind = options?.hostname === undefined ? {} : { hostname: options.hostname }
3823
3834
  const nativeRoutes = wsHandlers === undefined ? this.buildBunNativeRoutes() : undefined
3824
3835
  const running = (wsHandlers === undefined
3825
3836
  ? Bun.serve({
3826
3837
  port,
3827
3838
  reusePort,
3839
+ ...bind,
3828
3840
  ...(nativeRoutes === undefined ? {} : { routes: nativeRoutes }),
3829
3841
  fetch: (req: Request) => this.fetch(req),
3830
3842
  })
3831
3843
  : Bun.serve<BunWsData>({
3832
3844
  port,
3833
3845
  reusePort,
3846
+ ...bind,
3834
3847
  fetch: (req, server) => this.bunFetchWithWebSocket(req, server),
3835
3848
  // Bun's `ServerWebSocket<BunWsData>` is runtime-compatible with the handlers' structural
3836
3849
  // `BunSocket` view (kept local so `Bun.*` types never leak into the published .d.ts); the