ADR-007: Deterministic ports for time and ID generation
Date: 2025-01-15 (Phase 1) Status: Accepted Supersedes: — Superseded by: —
Context
IRC protocol behaviour is time-sensitive: idle/PING timers, WHOIS signon
time, server-time tags (ISO-8601 timestamps on every message), SASL nonce
generation, chathistory message IDs. A traditional ircd calls Date.now()
and crypto.randomUUID() directly in handlers. This makes tests
non-deterministic: the same input produces different output on every run,
depending on when the test executed.
Non-determinism is fatal for the parametrized contract suite (TICKET-032),
which runs identical scenarios across in-memory, CF, and AWS runtimes. If a
reducer embeds Date.now(), the expected @time= tag changes every
millisecond and the assertion cannot be written.
Decision
Time and ID generation are injected ports, not global calls:
interface Clock {
now(): number; // epoch milliseconds
}
interface IdFactory {
nonce(): string; // unique message ID for message-tags/chathistory
traceId(): string; // per-request trace ID for observability
}
Both are passed through Ctx to every reducer. Reducers read ctx.clock.now()
and ctx.ids.nonce() — never Date.now() or Math.random().
Concrete implementations:
FakeClock(tests): returns a fixed value set at construction. Deterministic:new FakeClock(10_000)always reportsnow() === 10_000.SystemClock(production): wrapsDate.now().SequentialIdFactory(tests): returnsid-0,id-1,id-2, … Deterministic: the same sequence of calls produces the same IDs.RandomIdFactory(production): wrapscrypto.randomUUID().
Consequences
Positive:
- Reducer tests are fully deterministic. The same
(state, message, ctx)triple always produces the same{ state, effects }. No flaky tests. - The contract suite can assert exact wire output including
@time=tags andmsgid=tags, because the fake clock and ID factory are pinned. - Time travel is trivial in tests: advance the
FakeClockbetween messages to test idle/PING logic, flood-control refill, and chathistory bounds. - The reducer purity contract (ADR-001) is enforceable: a reducer that calls
Date.now()is simply not using the injected port, and the test will fail because the output is wrong.
Negative:
- Every call site that needs time or an ID must thread
ctx— there is no shortcut. This is a small constant cost per reducer. - The
ClockandIdFactorymust be consistent across the actor's lifetime. The actor pins them at construction; switching mid-connection would produce inconsistent timestamps. This is enforced by the actor's readonly fields. Date.now()resolution is milliseconds; sub-millisecond ordering is not possible with this port. This has not been a problem for IRC (message ordering is preserved by connection/channel authority serialization, not by timestamp).
References
- PLAN §9 — "Time/IDs: injected ports (Clock, IdFactory) for deterministic tests"
- PLAN §8 — "Determinism rules: inject a Clock and deterministic ID/nonce ports. No real timers/random in reducers."
- ADR-001 — pure reducers (why determinism matters)
packages/irc-core/src/ports.ts—Clock,IdFactory,FakeClock,SequentialIdFactory,SystemClock,RandomIdFactorypackages/irc-core/src/types.ts—Ctx.clock,Ctx.ids