Someone did this
Part 04 of The Database That Remembers Why · Act I, Facts · November 8, 2026
import { Effect } from "effect";
import { retry } from "./story.ts";
export const answer = Effect.runSync(retry);
// answer.first.position -> 1
// answer.receipt?.position -> 1
// answer.journal.length -> 1The first request committed. The retry did not. Looking up the command's receipt finds the original position, not a second event that happens to look like the first.
Part 3 recorded when we learned something. It didn't record who asked us to change it or identify the request that caused the commit. Let's put those beside the facts, not in a separate log we hope stays in sync. This remains teaching code, using Effect 4.1.1, not Triplex's API.
Name the attempt that may succeed once
Keep the temporal transaction, and attach the command that produced it:
import { Effect, Semaphore } from "effect";
import type { Pattern } from "../01/facts.ts";
import { StoreError, type Change } from "../02/log.ts";
import {
readAt,
record,
wallClock,
type Basis,
type TimedFact,
type TimedTransaction,
} from "../03/temporal.ts";
export type Command = {
readonly id: string;
readonly actor: string;
readonly rules: string;
};
export type Receipt = TimedTransaction & {
readonly command: Command;
};The command ID identifies one intended submission. The actor identifies the principal claimed by the host. The rule label records the context the caller supplied. It does not yet identify executable, immutable rules; we'll expose that hole shortly.
A receipt is the committed temporal transaction plus that command. Keeping one object for both avoids an independent receipt write that could succeed when the facts fail, or fail after the facts succeed. Our journal is now the sequence of these receipts.
Neither the type nor the database authenticates actor. A real host must derive it from trusted request context, not let a caller impersonate Pat by typing a string. Attribution without authentication is just a recorded claim.
Lose the response, then retry
Imagine the server commits Maria's training but the response never reaches the caller. The caller resubmits the same command ID:
export const recordTraining: Command = {
id: "record-training:maria:request-17",
actor: "user:pat",
rules: "hr-2026.1",
};
export const retry = Effect.gen(function* () {
const store = makeStore(Effect.succeed(MAR_10));
const changes = [
{
op: "assert",
fact: {
...original,
validFrom: JAN_1,
},
},
] as const;
const first = yield* store.transact(recordTraining, changes);
const duplicate = yield* store
.transact(recordTraining, changes)
.pipe(Effect.flip);
const receipt = yield* store.receipt(recordTraining.id);
return {
first,
receipt,
error: duplicate.message,
journal: yield* store.journal,
};
});
// error:
// "command already committed: record-training:maria:request-17"
// first and receipt have position 1; journal has one entry.The duplicate fails with command already committed: record-training:maria:request-17. The caller can then fetch the original receipt. Both first and receipt have position 1; the journal has one entry. The receipt records user:pat and hr-2026.1 along with the assertion and its time.
This contract is deliberately explicit: duplicates are rejected, not silently returned as successes. A missing lookup returns undefined. We don't compare the retried body with the original body and pretend that matching IDs prove matching intent. The receipt shows what actually won.
The caller must preserve the ID across retries. Generating a new ID for each network attempt defeats this protection. Conversely, reusing an ID for a different business event is an error even if the facts differ.
Claim the ID where we commit the facts
Reuse Part 3's record operation. Reject an already committed command before staging a new transaction:
export const accept = Effect.fnUntraced(function* (
log: ReadonlyArray<Receipt>,
command: Command,
changes: ReadonlyArray<Change<TimedFact>>,
now: number,
) {
const existing = log.find((tx) => tx.command.id === command.id);
if (existing !== undefined) {
return yield* new StoreError({
message: `command already committed: ${command.id}`,
});
}
const tx = yield* record(log, changes, now);
return { ...tx, command: structuredClone(command) };
});The ID claim is the receipt's presence in the journal. There is no separate set to update before or after it. A failed assertion or retraction leaves no receipt, so that command ID is still available for a corrected attempt.
But a duplicate check alone isn't enough. It must share the commit boundary:
export const makeStore = (clock = wallClock) => {
let log: ReadonlyArray<Receipt> = [];
const lock = Semaphore.makeUnsafe(1);
const transact = (
command: Command,
changes: ReadonlyArray<Change<TimedFact>>,
) =>
lock.withPermit(
Effect.gen(function* () {
const now = yield* clock;
const receipt = yield* accept(log, command, changes, now);
log = [...log, receipt];
return structuredClone(receipt);
}),
);
const receipt = (id: string) =>
Effect.sync(() =>
structuredClone(log.find((tx) => tx.command.id === id)),
);
const match = (pattern: Pattern, basis: Basis) =>
Effect.sync(() => readAt(log, pattern, basis));
const journal = Effect.sync(() => structuredClone(log));
return { transact, receipt, match, journal };
};One permit covers the clock, ID lookup, staging checks, and append. If two callers race with the same command ID but different assertion IDs, only one can commit. This isn't accidental protection from assertion-ID collisions; command identity itself prevents the second event.
Reads and returned receipts are copies. Changing a returned actor doesn't rewrite who the journal says acted. As before, this protects one store instance in memory, not multiple processes or a durable database.
A rule label is not a rule
Try a training value that the intended domain should reject:
export const rulesGap = Effect.gen(function* () {
const store = makeStore(Effect.succeed(MAR_10));
yield* store.transact(recordTraining, [
{
op: "assert",
fact: {
...original,
value: "yes",
validFrom: JAN_1,
},
},
]);
return {
facts: yield* store.match(
{},
{
validAt: MAR_2,
recordedAt: MAR_10,
},
),
receipt: yield* store.receipt(recordTraining.id),
};
});
// The receipt says hr-2026.1. The value "yes" still commits.
// Recording a rule label doesn't enforce any rule.The string "yes" commits. The receipt dutifully records hr-2026.1. Nothing in the store knows what that label means, and nothing checks that training must be a boolean.
The journal is more useful now, but it must not overclaim. It says Pat submitted this statement in this named context. It does not establish that the context was applied, much less preserve the definition of that context. Those are two separate missing pieces, not something a metadata field can supply on its own.
What this version leaves out
IDs are local to this store. There is no persistence, authenticated host, authorization policy, command-body fingerprint, or external delivery. Rejecting a duplicate commit doesn't make an email or payment exactly-once. Such effects need their own host-side delivery and retry contract.
Rules are still labels. Attribute values remain unchecked beyond their primitive TypeScript types. We have preserved the submitted evidence and its claimed author, not validated the business claim.
This is what Triplex does
Triplex's real command envelope and journal commit command identity, attribution, changes, and receipts atomically. Duplicate command IDs fail with the library's own typed error; they are not silent successes. See Operational primitives for the actual contract and Host integration for the authentication and delivery responsibilities that remain outside the database. Our three-field command and StoreError are not those public APIs.
Next: check the world we're about to publish
We can say who submitted the string "yes" and prove that a retry did not submit it twice. We still accepted the wrong kind of value. And checking one value won't prevent two workers from claiming the same employee code. Part 5 puts schemas and cross-fact constraints inside the transaction.
The source and tests check the receipt, exact duplicate error, competing commands, failed-ID reuse, copy isolation, and the unenforced rule label. Back to the series.