A shape is not an invariant
Part 05 of The Database That Remembers Why · Act II, Rules · November 8, 2026
import { Effect } from "effect";
import { correction } from "./story.ts";
export const answer = Effect.runSync(correction);
// answer.message:
// Expected boolean
// at [":worker/trained"]
// answer.journal.length -> 1
// answer.receipt -> undefinedMaria's correction fails. The journal still has the onboarding transaction, and no receipt claims the correction committed. We finally reject the string "yes" instead of storing it as a training value.
Part 4 made the author and command visible without enforcing their named rules. This time we'll decode incoming changes and check the world they would create, using Effect Schema. It is teaching code on Effect 4.1.1, not Triplex's configuration or transaction API.
A value can have the right shape in the wrong world
Start with a worker's shape:
export const goodShape = {
":entity/type": "Worker",
":worker/code": "M-17",
":worker/trained": true,
};
export const shapeOnly =
Schema.decodeUnknownEffect(Worker)(goodShape);
// This shape is valid for Maria, and for a second worker.
// Shape alone cannot make the code unique across entities.That object has a worker marker, an employee code, and a boolean training value. Decoding it succeeds. Giving the same object to a second worker would succeed too. A Schema decoder seeing one object cannot know that another entity already claims M-17.
We'll enforce two different contracts: shape, including required attributes and their types, and uniqueness of employee codes among workers valid at the same instant. Codes are case-sensitive in this model. Display names aren't unique keys; Maria and another Maria may share a name.
Decode changes, then decode entities
Here are the schemas and the earlier pieces they reuse:
import { Effect, Schema, Semaphore } from "effect";
import type { Pattern, Value } from "../01/facts.ts";
import { StoreError, replay } from "../02/log.ts";
import { readAt, wallClock, type Basis } from "../03/temporal.ts";
import {
accept,
type Command,
type Receipt,
} from "../04/commands.ts";
const Fact = Schema.Struct({
id: Schema.String,
entity: Schema.String,
attribute: Schema.String,
value: Schema.Union([
Schema.String,
Schema.Number,
Schema.Boolean,
]),
validFrom: Schema.Number,
validTo: Schema.optionalKey(Schema.Number),
});
export const Changes = Schema.Array(
Schema.Union([
Schema.Struct({ op: Schema.Literal("assert"), fact: Fact }),
Schema.Struct({
op: Schema.Literal("retract"),
id: Schema.String,
}),
]),
);
export const Worker = Schema.Struct({
":entity/type": Schema.Literal("Worker"),
":worker/code": Schema.String,
":worker/trained": Schema.Boolean,
});Changes decodes the operation envelope and primitive fact values from unknown. Worker describes the assembled entity, not one triple. A string is a legitimate primitive fact value, but not a legitimate training value. Those checks occur at different levels.
For this version every entity is a Worker, and every attribute has exactly one value. The :entity/type sentinel is our teaching convention, not Triplex's entity-type registration API. We are not yet designing a general configuration language.
Assemble facts by entity and check both contracts:
export const checkEntities = Effect.fnUntraced(function* (
facts: ReturnType<typeof replay>,
) {
const entities = new Map<string, Record<string, Value>>();
for (const { entity, attribute, value } of facts) {
const body =
entities.get(entity) ??
(Object.create(null) as Record<string, Value>);
if (Object.hasOwn(body, attribute)) {
return yield* new StoreError({
message: `multiple values: ${entity} ${attribute}`,
});
}
body[attribute] = value;
entities.set(entity, body);
}
const codes = new Set<string>();
for (const body of entities.values()) {
const worker = yield* Schema.decodeUnknownEffect(Worker)(body, {
onExcessProperty: "error",
});
const code = worker[":worker/code"];
if (codes.has(code)) {
return yield* new StoreError({
message: `worker code already used: ${code}`,
});
}
codes.add(code);
}
});Two live assertions for the same attribute are an error even if their values agree. Required fields and value types come from Worker. Unknown attributes are rejected rather than silently stripped. The null-prototype object lets even an incoming attribute named __proto__ reach that check as data, instead of interacting with an inherited property.
The set of codes is shared across the entities being checked. That is the piece a shape decoder alone couldn't supply.
“Now” is not the whole timeline
Checking the state at commit time is tempting and wrong. Suppose training is required, but its assertion expires tomorrow while the worker's other attributes continue. Today's shape is valid. Tomorrow's is missing a field.
Our interval model changes only at assertion starts and ends. Check each boundary in the proposed recorded-time view:
export const checkTimeline = Effect.fnUntraced(function* (
log: ReadonlyArray<Receipt>,
) {
const boundaries = new Set(
replay(log).flatMap((fact) =>
fact.validTo === undefined
? [fact.validFrom]
: [fact.validFrom, fact.validTo],
),
);
const recordedAt = log.at(-1)?.recordedAt ?? 0;
for (const validAt of [...boundaries].sort((a, b) => a - b)) {
const facts = readAt(log, {}, { validAt, recordedAt });
yield* checkEntities(facts);
}
});At an end boundary the ending assertion is already absent; an assertion starting there is already present. This checks every distinct state on the timeline without polling every millisecond. Before the first boundary there are no entities to validate.
The scan uses the latest recorded view, so withdrawn evidence doesn't create an extra current entity. Older recorded views are left intact. All this is deliberately brute-force: replaying every boundary is a clear teaching algorithm, not a performance claim about Triplex.
Publish only after the checks succeed
Keep Part 4's ID check and receipt creation. Validate the proposed journal before making it the store's journal:
export const makeStore = (clock = wallClock) => {
let log: ReadonlyArray<Receipt> = [];
const lock = Semaphore.makeUnsafe(1);
const transact = (command: Command, input: unknown) =>
lock.withPermit(
Effect.gen(function* () {
const changes = yield* Schema.decodeUnknownEffect(Changes)(
input,
{ onExcessProperty: "error" },
);
const now = yield* clock;
const receipt = yield* accept(log, command, changes, now);
const proposed = [...log, receipt];
yield* checkTimeline(proposed);
log = proposed;
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 };
};Decoding, ID claiming, temporal validation, uniqueness, and append share one permit. Otherwise two callers could both observe an unused code and publish individually valid but jointly conflicting workers.
A rejection leaves facts, receipt, and transaction position untouched. There is no “compensating” transaction to erase a half-accepted command. The failed command ID remains available for a corrected attempt.
Try the correction again
Onboard Maria with code M-17, then replace her boolean training value with a string:
export const onboard = (entity: string, code: string) =>
(
[
[":entity/type", "Worker"],
[":worker/code", code],
[":worker/trained", true],
] as const
).map(([attribute, value], index) => ({
op: "assert" as const,
fact: {
id: `${entity}:${index}`,
entity,
attribute,
value,
validFrom: JAN_1,
},
}));
export const correction = Effect.gen(function* () {
const store = makeStore(Effect.succeed(MAR_10));
yield* store.transact(
recordTraining,
onboard("worker:maria", "M-17"),
);
const command = { ...recordTraining, id: "correct:maria" };
const error = yield* store
.transact(command, [
{ op: "retract", id: "worker:maria:2" },
{
op: "assert",
fact: {
id: "training:corrected",
entity: "worker:maria",
attribute: ":worker/trained",
value: "yes",
validFrom: JAN_1,
},
},
])
.pipe(Effect.flip);
return {
message: error.message,
current: yield* store.match(
{},
{
validAt: MAR_2,
recordedAt: MAR_10,
},
),
receipt: yield* store.receipt(command.id),
journal: yield* store.journal,
};
});
// The failed correction leaves Maria trained, one journal
// entry, and no receipt for correct:maria.The opening error comes from Schema: Expected boolean, followed by at [":worker/trained"]. Effect.flip makes the expected failure a value so the story can inspect it. The stored training remains true; the journal has one entry, and correct:maria has no receipt.
Now give Lee the same employee code:
export const duplicateCode = Effect.gen(function* () {
const store = makeStore(Effect.succeed(MAR_10));
yield* store.transact(
recordTraining,
onboard("worker:maria", "M-17"),
);
const error = yield* store
.transact(
{ ...recordTraining, id: "onboard:lee" },
onboard("worker:lee", "M-17"),
)
.pipe(Effect.flip);
return {
message: error.message,
journal: yield* store.journal,
};
});
// message: "worker code already used: M-17"
// The journal still has one entry.Both worker shapes would decode, but the proposed world fails with worker code already used: M-17. Again the journal has one entry. The tests also race two code claims, then retry the loser with a distinct code: one initial commit wins, and the corrected attempt gets position 2.
What this version leaves out
One entity type, three required attributes, scalar values, and one unique key. No references, deletions with cascading policies, schema registry, or incremental constraint indexes. Entirely retracting an entity removes it from validation; there is no separate existence record.
Most importantly, this validator checks the whole proposed timeline against today's hard-coded rules. It doesn't distinguish newly introduced violations from existing ones and offers no migration or grandfathering policy. Changing the decoder can make the next write fail because of an unrelated old entity. That is a limitation, not automatic correctness.
This is what Triplex does
Real Triplex describes attributes and entity types with Effect Schema and checks opted-in constraints at the transaction boundary. Its enforcement policy rejects new or worsened violations rather than blindly using this full-state teaching check. See Configuration and the configuration walkthrough for actual schemas, constraints, releases, and enforcement modes. Current state remains the maturity contract.
Next: which rules accepted this fact?
The receipt still says hr-2026.1, but the executing rules live in a mutable source file. Change Worker tomorrow and that label explains nothing about what accepted yesterday's transaction. We need to identify and retain the rule definitions, not just remember a name.
Part 6, “Rules are releases,” is coming. It will give immutable rule content an identity and separate it from the movable reference that chooses what accepts the next write. This installment stops here for review.
The source and tests check quoted results, invalid shapes, excess attributes, cardinality, future expiry boundaries, concurrent uniqueness, and atomic rejection. Back to the series.