Website · Why · Define · Verify · Monitor · Contribute
Write a behavior law once. Falsify it with generated inputs, verify recorded traces, and monitor live events with the same serializable definition.
eventlawis a public beta for design partners and experiments. Its core is tested; maintainer dogfood and independent API validation remain in progress.
Install the current public beta with:
npm install eventlaw@betaThe supported runtimes and stability of each entry point are documented in the public contract.
The same production rule usually exists three times:
- as an assertion in a test;
- as a query over recorded events;
- as an alert in production.
Those copies drift. They disagree about deadlines, correlation keys, partition boundaries, and what an incomplete trace means. When one fails, the result is often a boolean or a large log dump instead of the smallest sequence that explains the violation.
eventlaw turns the rule into portable data:
flowchart LR
L[Serializable law AST] --> G[Generated tests]
L --> R[Recorded traces]
L --> M[Live monitor]
G --> C[Readable counterexample]
R --> C
M --> C
No LTL notation, wall clock, event bus, agent framework, or runtime dependency is required by the core.
import { after, atMostOnce, defineLaws, event, never, ref } from 'eventlaw'
const laws = defineLaws({
paymentCompletes: after(event('payment.requested').capture('paymentId', 'id'))
.eventually(event('payment.captured').equals('id', ref('paymentId')))
.within(5_000)
.partitionBy('accountId'),
noCaptureWhileFrozen: never(event('payment.captured'))
.between(event('account.frozen'), event('account.unfrozen'))
.partitionBy('accountId'),
oneCapturePerRetryWindow: atMostOnce(event('payment.captured'))
.per('id')
.within(24 * 60 * 60 * 1_000),
})The definition is plain JSON-safe data. It can cross a worker boundary, be saved beside a trace, or run in a different process without serializing JavaScript functions.
import { formatReport, verifyTrace, type TraceEvent } from 'eventlaw'
const trace: TraceEvent[] = [
{ type: 'payment.requested', accountId: 'a-17', id: 'p-42', at: 1_000 },
{ type: 'process.crashed', accountId: 'a-17', at: 2_000 },
]
const report = verifyTrace(trace, laws, { complete: true, now: 6_000 })
console.log(formatReport(report, trace))paymentCompletes failed
no payment.captured arrived within 5000ms of payment.requested
partition: "a-17"
+ 0ms payment.requested {"accountId":"a-17","id":"p-42"}
+ 5000ms deadline
Reports use pass, fail, or pending. A law that never saw its trigger passes
vacuously and says so, which catches misspelled event names and empty fixtures
that would otherwise look green.
The optional eventlaw/fast-check adapter shrinks both the generated input and
the behavioral trace it emitted:
import fc from 'fast-check'
import { falsify } from 'eventlaw/fast-check'
const failure = await falsify({
arbitrary: fc.array(commandArbitrary),
run: (commands) => runSystem(commands),
laws,
law: 'noCaptureWhileFrozen',
seed: 42,
})The first real integration runs against the published
@luantaraschi/lull reducer. A planted
timer bug shrinks to two commands and a two-event counterexample:
Minimal generated case: ["takeover","tick"]
Minimal counterexample: 2 events
fast-check is an optional peer dependency and is isolated from the main entry.
import { createMonitor, monitoringProfile } from 'eventlaw'
const monitor = createMonitor(laws)
monitor.push({
type: 'payment.requested',
accountId: 'a-17',
id: 'p-42',
at: 1_000,
})
monitor.advanceTo(6_000) // deadlines also fail during silence
console.log(monitoringProfile(laws))
console.log(monitor.stats())The monitor is incremental: it does not keep or re-verify the complete trace. An observed violation is terminal because a late event cannot rewrite the fact that a deadline elapsed.
eventlaw does not hide retention behind a cache option:
| Law | Memory class | What is retained |
|---|---|---|
eventually(...).within(d) |
window-bounded |
unmatched triggers until consequence or deadline |
never(...).between(A, B) |
scope-bounded |
one start for each open partition |
atMostOnce(...).per(key) |
unbounded |
every distinct key for the monitor lifetime |
atMostOnce(...).per(key).within(d) |
window-bounded |
latest keys inside the inclusive window |
atMostOnce(...).per(key).resetOn(R) |
scope-bounded |
keys since the last reset in each partition |
monitoringProfile(laws) reports the static class and rationale.
monitor.stats() reports the logical entries currently retained. Lifetime
uniqueness remains unlimited unless the law explicitly chooses a window or a
scope reset.
Node.js 22 or newer is required.
git clone https://github.com/luantaraschi/eventlaw.git
cd eventlaw
npm ci
npm run checkThree examples exercise different parts of the project:
npm run example:lull # real reducer + minimal temporal failure
npm run example:falsify # generated commands + two-stage shrinking
npm run example:webhooks # bounded delivery deduplication
npm run example:jsonl # verify a recorded JSONL trace
npm run bench:progress # progress-monitor scalingRecorded traces can be read incrementally from the dependency-free
eventlaw/jsonl subpath:
import { createReadStream } from 'node:fs'
import { readJsonl } from 'eventlaw/jsonl'
const trace = await readJsonl(createReadStream('events.jsonl'), {
source: 'events.jsonl',
})
const report = verifyTrace(trace, laws, { complete: true })Malformed records report their source and one-based line number. The adapter
expects each line to already contain an event with a non-empty type and finite
millisecond at; source-specific normalization stays outside the semantic core.
OTLP/JSON event batches have a dedicated dependency-free adapter:
import { eventsFromOtlpJson } from 'eventlaw/opentelemetry'
const { trace, skippedLogRecords } = eventsFromOtlpJson(otlpPayload)
const report = verifyTrace(trace, laws, { complete: true })Only log records with a non-empty OpenTelemetry eventName become events.
timeUnixNano is preferred over observedTimeUnixNano and truncated to explicit
milliseconds; exact timestamp strings remain under otel. The body stays under
body, while attributes, resource, and instrumentation scope stay namespaced
under otel so none can overwrite type or at.
The mapping is tested both against the protocol's official fixture and an
OTLP/HTTP request captured from @opentelemetry/sdk-logs and
@opentelemetry/exporter-logs-otlp-http. Empty AnyValue bodies emitted by the
JavaScript SDK are treated as absent, while ordinary logs remain visible through
skippedLogRecords.
The same SDK batch was also sent through the official OpenTelemetry Collector with JSON re-export. Default-field omission and canonical int64 strings produce the exact same converted trace as the direct SDK request.
A second Collector capture batches Events from checkout-api and
fulfillment-worker into one request. Their resource metadata stays distinct,
while traceId, spanId, and trace flags remain available under otel. A law
can therefore correlate an order across services without flattening resource
identity:
after(
event('order.accepted')
.capture('orderId', 'otel.attributes.order.id')
.capture('traceId', 'otel.traceId'),
)
.eventually(
event('order.shipped')
.equals('otel.attributes.order.id', ref('orderId'))
.equals('otel.traceId', ref('traceId')),
)
.within(6_000)eventlaw is a small runtime-verification core for event traces. It owns law
definitions, deterministic semantics, counterexample minimization, and
incremental operator state.
It is not an event bus, workflow engine, telemetry backend, test runner, or
general-purpose temporal-logic solver. It does not connect to Kafka or an
OpenTelemetry Collector. Adapters translate source payloads into TraceEvent
objects without coupling the semantic core to their SDKs.
The current vertical slice includes:
- JSON-safe matchers with field equality, array membership, captures, and refs;
- progress, exclusion, and uniqueness operators;
- partitions, explicit time, three-valued reports, and vacuity warnings;
- deletion-minimal failing traces and readable timelines;
- an incremental monitor with observable retention;
- differential tests proving online/offline prefix equivalence;
- optional property-based generation and shrinking;
- incremental JSONL trace ingestion with line-aware diagnostics;
- structural OTLP/JSON event conversion tested against the official fixture.
The full suite has 69 tests across 10 files, including 1,250 generated differential traces. TypeScript types, ESM, CommonJS, declarations, formatting, and the package tarball are checked locally and in CI. A clean-consumer smoke test also installs the tarball and imports every entry point through both module systems.
The current validation step installs the packed candidate in maintainer-owned projects before putting the API in front of external TypeScript developers. This dogfood produces integration evidence but does not replace independent feedback. The first performance decision is documented in benchmarks, including the quadratic baseline that justified the deadline index.
- Law ASTs contain data, not predicate functions.
- Time comes from events or
advanceTo; the core never reads the wall clock. - The main entry has no runtime dependencies.
- Online memory claims are operator-specific and observable.
- Retention changes business meaning, so eviction is never implicit.
- Finite and online verification must agree until the first terminal failure.
The normative rules live in the semantic spec. Accepted trade-offs and their rationale are recorded in DECISIONS.md. The adapter strategy records the JSONL and OpenTelemetry mapping contracts and what must be learned before Kafka or durable-state integration. The release roadmap separates the beta gate from work that can be contributed after launch.
Concrete traces are the best feature requests. If a production rule is hard to express, open an issue with the smallest event sequence that should pass or fail and write the rule once in plain language.
The external validation protocol defines the comprehension and webhook-operator sessions that remain open during the public beta. The maintainer dogfood protocol defines what to test first in owned projects and how to record evidence without turning assumptions into facts.
CONTRIBUTING.md covers setup, tests, semantic changes, and pull request expectations. Participation is governed by the Code of Conduct. Security reports should follow SECURITY.md.
MIT © Luan Taraschi