Two clocks, two questions
Part 03 of The Database That Remembers Why · Act I, Facts · November 8, 2026
import { Effect } from "effect";
import { twoClocks } from "./story.ts";
export const answer = Effect.runSync(twoClocks);
// answer.asKnownThen.map((f) => f.id) -> ["training:1"]
// answer.asKnownNow.map((f) => f.id) -> []
// answer.january.map((f) => f.id) -> ["training:2"]Three answers, no contradiction. On March 2, our evidence said Maria was trained. On March 10, we learned her training had ended on March 1. Our corrected view no longer covers March 2, but it still covers January.
Part 2 preserved the statements. Now we need to distinguish when a statement holds from when we recorded it. This is teaching code using Effect 4.1.1, not Triplex's API.
Two coordinates for one question
An assertion gets a valid interval. A transaction gets a recorded time. A read must supply both coordinates explicitly:
import { DateTime, Effect, Semaphore } from "effect";
import type { Pattern } from "../01/facts.ts";
import {
StoreError,
matches,
replay,
stage,
type Assertion,
type Change,
type Transaction,
} from "../02/log.ts";
export type TimedFact = Assertion & {
readonly validFrom: number;
readonly validTo?: number;
};
export type TimedTransaction = Transaction<TimedFact> & {
readonly recordedAt: number;
};
export type Basis = {
readonly validAt: number;
readonly recordedAt: number;
};
export const wallClock = DateTime.now.pipe(
Effect.map(DateTime.toEpochMillis),
);validFrom is inclusive; validTo is exclusive. An omitted end means the interval is open-ended, not that the certificate can never expire. recordedAt belongs to the transaction: everything in one commit becomes known together.
Both coordinates use epoch milliseconds. Valid time is supplied with the statement. Recorded time comes from the store's clock, not a per-write timestamp chosen by the client. DateTime.now supplies the default; the store accepts a clock Effect so the story and tests can control time without waiting or backdating a live request.
Here are the story's UTC instants; JavaScript numbers months from zero:
export const JAN_1 = Date.UTC(2026, 0, 1);
export const JAN_5 = Date.UTC(2026, 0, 5);
export const MAR_1 = Date.UTC(2026, 2, 1);
export const MAR_2 = Date.UTC(2026, 2, 2);
export const MAR_10 = Date.UTC(2026, 2, 10);All five dates are in 2026. These are precise boundaries at midnight UTC, not a model of local working days or time zones.
Record a correction when it arrives
On January 5, we record training effective from January 1. On March 10, we replace that open interval with one ending on March 1:
export const twoClocks = Effect.gen(function* () {
let now = JAN_5;
const store = makeStore(Effect.sync(() => now));
yield* store.transact([
{
op: "assert",
fact: {
...original,
validFrom: JAN_1,
},
},
]);
now = MAR_10;
yield* store.transact([
{ op: "retract", id: original.id },
{
op: "assert",
fact: {
...original,
id: "training:2",
validFrom: JAN_1,
validTo: MAR_1,
},
},
]);
return {
asKnownThen: yield* store.match(
{},
{
validAt: MAR_2,
recordedAt: MAR_2,
},
),
asKnownNow: yield* store.match(
{},
{
validAt: MAR_2,
recordedAt: MAR_10,
},
),
january: yield* store.match(
{},
{
validAt: JAN_5,
recordedAt: MAR_10,
},
),
journal: yield* store.journal,
};
});
// asKnownThen: [training:1] — we believed Maria was trained.
// asKnownNow: [] — the corrected interval ended on March 1.
// january: [training:2] — January is still covered.The correction does not assert that Maria was untrained forever. It withdraws the old assertion and supplies a narrower interval. An empty answer on March 2 means no matching training assertion, not a stored boolean false.
Notice that the retraction has no valid interval of its own. At its recorded time it withdraws the entire old assertion from our newer view, including the old assertion's claims about January. The replacement explicitly preserves January. Earlier recorded-time views still see the old assertion.
Recorded time first, valid time second
The order of the read matters:
export const readAt = (
log: ReadonlyArray<TimedTransaction>,
pattern: Pattern,
basis: Basis,
) => {
const known = log.filter(
(tx) => tx.recordedAt <= basis.recordedAt,
);
return structuredClone(
replay(known).filter(
(fact) =>
fact.validFrom <= basis.validAt &&
(fact.validTo === undefined ||
basis.validAt < fact.validTo) &&
matches(fact, pattern),
),
);
};First select transactions known at the requested recorded time. Then replay their assertions and retractions using Part 2. Finally select assertions whose valid intervals cover the requested instant.
If we replayed the entire journal first, the March 10 retraction would leak into the March 2 view. We'd have preserved the old statement in the journal but still lost the ability to reconstruct the earlier belief.
The opening results follow from those steps:
| Question | Valid time | Recorded time | Answer |
|---|---|---|---|
| What did we believe then? | March 2 | March 2 | training:1 |
| What do we believe now about then? | March 2 | March 10 | No matching assertion |
| Did the correction erase January? | January 5 | March 10 | training:2 |
Two time coordinates are not an extra filter on today's rows. They select which history is allowed to participate in the answer.
Keep the clock honest
Reuse Part 2's staging checks and add interval and clock checks:
export const record = Effect.fnUntraced(function* (
log: ReadonlyArray<TimedTransaction>,
changes: ReadonlyArray<Change<TimedFact>>,
recordedAt: number,
) {
const last = log.at(-1)?.recordedAt ?? 0;
if (!Number.isFinite(recordedAt) || recordedAt < last) {
return yield* new StoreError({
message: "recorded clock must not move backwards",
});
}
for (const change of changes) {
if (change.op !== "assert") continue;
const { id, validFrom, validTo } = change.fact;
if (
!Number.isFinite(validFrom) ||
validFrom < 0 ||
(validTo !== undefined &&
(!Number.isFinite(validTo) || validTo <= validFrom))
) {
return yield* new StoreError({
message: `invalid valid interval: ${id}`,
});
}
}
const tx = yield* stage(log, changes);
return { ...tx, recordedAt };
});This teaching model permits finite, nonnegative valid starts and finite ends strictly after the start. A zero-length or reversed interval for the original assertion fails with invalid valid interval: training:1. The recorded clock cannot go backwards; that failure says recorded clock must not move backwards. Neither failure appends anything.
Equal recorded times are allowed. A read at that millisecond includes all commits at that time. Positions still order them, but this version has no read coordinate for selecting between same-millisecond commits.
The store is the same small ownership boundary as before:
export const makeStore = (clock = wallClock) => {
let log: ReadonlyArray<TimedTransaction> = [];
const lock = Semaphore.makeUnsafe(1);
const transact = (changes: ReadonlyArray<Change<TimedFact>>) =>
lock.withPermit(
Effect.gen(function* () {
const now = yield* clock;
const tx = yield* record(log, changes, now);
log = [...log, tx];
return structuredClone(tx);
}),
);
const match = (pattern: Pattern, basis: Basis) =>
Effect.sync(() => readAt(log, pattern, basis));
const journal = Effect.sync(() => structuredClone(log));
return { transact, match, journal };
};The semaphore protects clock sampling, staging, and publication together. readAt is pure over the journal supplied to it; the store supplies a consistent array and returns copied answers.
What this version leaves out
No persistence or temporal indexes. Every query replays history. No automatic expiry notifications: asking after an interval ends changes the answer, but nothing wakes a host up. Part 9 will address that.
The attributes still accept any primitive value. There are no authenticated actors, command IDs, receipts, or versioned rules. Our clocks describe what was recorded, not an independent proof that the real-world event happened.
This is what Triplex does
Real Triplex reads facts with independent valid and recorded coordinates and keeps correction history. Read Core concepts for its interval semantics and actual query contract. This miniature's numeric Basis and makeStore are not library exports; backend maturity is still documented in Current state.
Next: a timestamp isn't an author
We can reconstruct what the store knew before the correction. We cannot say who submitted it, or tell a retried request from a new business event. “It was recorded on March 10” isn't an answer to “who did this?” Part 4 puts a command and its receipt in the same commit as the facts.
The source and tests pin the dates, all three answers, both interval boundaries, retraction visibility, and failed clock checks. Back to the series.