Skip to main content
The contracts and the deterministic receipt assembler behind Haia Trace. Most people reach for the CLI or a capture adapter (x402, Circle) instead. Depend on this package directly when you are writing your own capture adapter or embedding the assembler. Zero runtime dependencies, ESM only, Node ≥ 22. The root export touches no node:* API and runs in any runtime; the file-backed sink lives behind the /file subpath and the Control Plane sink behind /cp.

Install

The three contracts

Event

What a capture adapter emits — one normalized observation. Persisted append-only as NDJSON, one event per line, so field names are snake_case and values are plain JSON. event_type and role are open strings: the vocabulary belongs to the adapter, not to core.

Template

The expected shape of an operation, as data. A stage’s match is a match-set — any one of its events closes the stage — and a witness may name the role that has to have observed it.
Core defines the shape (OperationTemplate) and the validator; reading YAML off disk is the caller’s job. The CLI ships its own templates and its own loader — see haia-trace template.

Receipt

The verdict (events, template) produces. See Operation Receipt for the model, and haia-trace receipt for reading one back.

Assemble a receipt

unassigned holds the events with no context_id — session attestations, or out-of-band signals such as a chain confirmation added later. They belong to no single operation, so they are handed back rather than attributed or dropped.
An OperationTemplate is plain data. Validate one you did not author with assertOperationTemplate, which throws a sourced Error on the first mismatch — a malformed template must fail loudly, not produce a wrong receipt.

Determinism

The assembler is pure: no wall clock, no random id, no I/O, no model. Given the same events and template it returns a byte-identical receipt, which is what lets one be reproduced or hashed. Receipt identity and timestamps are the caller’s concern; nothing is stamped here. Events are put into a total order before folding — by occurred_at, then seq, then event_id — so shuffled input, or events merged from two capture sessions, yield the same receipt.
Two things that order relies on. occurred_at is compared lexically, which is chronological only because the Event Contract fixes the format to ISO-8601 UTC at millisecond precision — an adapter emitting an offset instead of Z breaks it. And each event must be fed in at most once: there is no dedupe by event_id, so reading the same run file twice counts its events twice.

Streaming

assembleProgressively and assembleReceiptsProgressively are generators over the same inputs, yielding a snapshot after each event — a baseline at 0, then processed counting to total. The last snapshot equals the eager result. This is what backs the progress indicator in haia-trace build.

Record events

createRecorder is the one place an adapter mints events. It owns the session’s seq counter and stamps event_id, occurred_at, seq, and adapter:
context_id is never generated for you: it groups the events of one operation, so it has to come from your runtime context or a protocol request id. An adapter that records straight to one sink binds it at construction and calls record instead — stamp and write in one call, with no way to mint an event and forget to persist it. It returns the sink’s verdict:
record on a recorder built without a writer throws a TypeError naming the fix — a wiring bug that fails on the first call, before any event is at stake.
Pass occurred_at when your source learns about a fact after it happened — a webhook delivery, a chain query. Deliveries retry and arrive out of order, so capture time is not fact time, and stamping the clock would order the operation by delivery accidents. In-process adapters omit it and get the recorder’s clock. now and newId are injectable too, which is how tests stay deterministic.

Sinks

A sink is where a recorder puts events. The contract lives in the root export and is runtime-agnostic — EventWriter, EventReader, SinkErrorHandler and the NDJSON codec (encodeEventLine, decodeEventLine, decodeEventLines) — so a browser or edge adapter can implement it without pulling in node:fs. write(event) never throws, and returns whether the sink accepted the event. Ignore the verdict and you get plain fail-open capture; read it and a caller with real delivery semantics — a webhook receiver that must answer 500 for the source to redeliver — can act on a refusal without any sink ever throwing. Three ship with the package:

File

The file-backed sink is behind the /file subpath — the only module in the package that imports node:*:
createRunEventWriter(dir, opts) owns the naming — one file per session. When you need a fixed path instead, createFileEventWriter(path, onError) appends to exactly that file. Both store inside write, so a true from either means the line is on disk — which is what lets @usehaia/trace-circle hand one straight to a webhook route. Runs are a list rather than a reader over all of them at once on purpose. Events carry no run id, and context_id is only unique within a run — an adapter may number operations per session — so concatenating two runs and grouping by context_id would fold unrelated operations into one receipt. Every path is an argument; this package owns no directory. The producer and whoever reads the runs back must be pointed at the same one. haia-trace uses .trace/events unless --dir says otherwise, which is what the example matches. A relative path resolves against the working directory; pass an absolute one when the cwd is not fixed.

Control Plane

createCpEventWriter, behind the /cp subpath, uploads the same events to a Haia Control Plane project, so a run can also be looked at in the dashboard. It is a mirror, not a replacement: the local run file stays what the assembler reads, and nothing here reads back. It reaches the network through fetch alone — no node:*, no dependency — so it runs wherever your recorder does.
Where an event lands: Note what session_id is not: the run. One run is one start of your service and can hold many unrelated payments, so grouping by it would merge them. agentId is required rather than defaulted, for the same reason — a run id names a capture, not a payer, so events from one agent across restarts would land as different subjects, and nothing on the outside would show that the identity was invented. write() queues and returns; a batch goes out when it fills (100 events by default) or the flush interval elapses (2s). flush() settles what has been queued and never rejects — await it before a process exits, or the last batch leaves with it. Configuration is validated eagerly and throws: a bad URL or a missing key is a wiring bug, not a per-event fault.
true from this sink means buffered, not delivered — it hands events to the network later, so a delivery fault reaches onError, never the return. Only what is knowable synchronously answers false: a closed writer, an unsendable event, a full queue.
Nothing here reads the environment: the writer is configured through its constructor only. Read your own config and pass the values, so a run’s destination is decided in one visible place rather than by ambient state.

Multicast

createMulticastEventWriter(...writers) fans one stream out to several sinks and is itself an ordinary EventWriter, so the producer never learns there is more than one. Recording to disk and to the Control Plane is the usual reason:
Every writer is offered every event, whatever the others do — one sink that fails does not deny the events to the rest. flush() settles all of them and resolves however each one went. write returns the AND of the writers’ verdicts: true only when every sink accepted. That is the reading a caller acting on the result needs, since “all sinks have it” is the only answer that lets a webhook receiver acknowledge a delivery. A sink you treat as best-effort belongs outside the multicast, called separately — the fan-out will not guess which failures matter.

Fail-open

A producer sits in a payment path, so a sink failure must degrade to “capture stopped”, never “payment broke”. write does not throw on any of the three — faults go to onError, and a refusal comes back as false. For the file sink: if the run file cannot be created for a reason no later write can clear (usually a directory the process may not write to), you get one report and a writer that accepts events and drops them, rather than one identical error per event. A transient failure such as the open-file limit is reported and then recorded through once it clears. Calling createRunEventWriter with no directory is a caller mistake and still throws. The Control Plane sink is fail-open but not quiet: dropped batches and events the ingest API refused are both reported, so “the Control Plane has nothing” is never forged from a failure.
Wire onError on any producer whose capture matters. Recording nothing is the right outcome when the disk or the network refuses, but without a handler that refusal has nowhere to go and the run is simply absent.