@nifrajs/core 1.11.0 → 1.13.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.13.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"
@@ -28,6 +29,7 @@ export * from "./ledger.ts"
28
29
  export * from "./manifest.ts"
29
30
  export * from "./mount.ts"
30
31
  export * from "./reflection.ts"
32
+ export * from "./router/pattern.ts"
31
33
  export { METHODS, type Method, Router, type RouterMatch } from "./router/router.ts"
32
34
  export type {
33
35
  InferInput,
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 = {},