Skip to content

Author Your Entries

Everything that ever enters a harmos journal is a transaction or a record, and you write both as plain Rust structs. This chapter is the two-method contract a change implements, the one attribute that stamps durable identity onto it, and the question that decides which kind you are writing. At the end your domain logic is testable with cargo test and nothing running.

Everything that ever enters a harmos journal is one of two kinds. A transaction is a change: it mutates state, and replaying it is how state is rebuilt. A record is a fact: something that happened which belongs in the story, but which changes nothing. You author both as plain Rust structs.

Here is the schematic editor's "move a symbol":

transactions/symbol/move_symbol.rs
#[harmos::transaction(id = "symbol.move", version = 1)]
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, Serialize)]
pub struct MoveSymbol {
/// Which symbol moves.
pub symbol: SymbolId,
/// Where it goes.
pub to: Point,
}
impl Transaction<Schematic> for MoveSymbol {
type Error = SchematicError;
fn check(&self, schematic: &Schematic) -> Result<(), Self::Error> {
schematic.require_symbol(self.symbol)
}
fn apply(&self, schematic: &mut Schematic) {
schematic.symbol_mut(self.symbol).position = self.to;
}
}

Two lines above the methods are worth reading first.

The derives are yours to write. The attribute stamps identity and nothing else, so a definition brings its own: Serialize and Deserialize, because the struct's serde shape is the payload that gets stored; Clone, because the catalog that holds it is cloned on the way into history; and Debug, PartialEq, Eq because that is what makes the focused tests below readable. The example's definitions all carry exactly this line.

The error type is the application's, not the definition's. type Error = SchematicError names one vocabulary the whole editor refuses in, and every definition in the schematic example names that same type. One enum per definition looks tidier and does not survive contact with the commit call: journal.commit(MoveSymbol { .. }) hands the writer a leaf type, and the error that comes back has to be one type for every leaf, which is exactly what a shared application error is. Chapter 3 is where you declare it once; chapter 18 is where the layering is worked out in full.

The contract is two methods with sharply different personalities:

check is the bouncer. It reads state (&Schematic — it cannot mutate) and may refuse with your own typed error. Rejection is a domain answer ("no such symbol"), not a framework code. If check refuses, nothing happened: no mutation, no entry, no trace.

apply is the act. It mutates — and it is infallible: no Result, no escape hatch. This is not harmos being optimistic; it's a division of labor. By the time apply runs, the transaction has been accepted. A failure halfway through mutation would leave state half-changed, and there is no cheap way back (harmos never requires Clone on your state — no draft copies of a 200-sheet schematic). So the contract forces the discipline: everything that could go wrong must go wrong in check. If you find yourself wanting apply to fail, you've discovered a missing check.

Once that check exists, apply is written assuming it passed: schematic.net_mut(self.net) may presume the net is there. No defensive re-checking, no if let hedging. Check earns apply's confidence; apply spends it.

One rule surprises people: check runs exactly once, at commit time — never during replay. When state is rebuilt from history, only apply runs. Why? Because the entry in history was already accepted, under the rules that existed then. If tomorrow's stricter check could veto yesterday's accepted history, old files would refuse to open. History is settled; validation is for the present.

Two consequences follow, and both catch people out the first time. Migration (chapter 10) is about decoding an older shape and preserving its behavior, never about re-validating it. And a tightened rule means today's state can legally violate today's check: checks stop new violations, they do not erase old ones. Cleaning up an existing violation is a new transaction, appended like any other — never a rewrite of history. Concretely, if v2 forbids duplicate reference designators, v2's check must tolerate finding a duplicate while refusing to create one.

records/export_completed.rs
#[harmos::record(id = "export.completed", version = 1)]
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, Serialize)]
pub struct ExportCompleted {
/// What it was written as.
pub format: ExportFormat,
/// How many symbols it covered.
pub symbols: u32,
}

That's the whole thing. No trait implementation — a record has no behavior, because a record changes nothing. It's a witnessed fact appended to the same single ordered history as transactions. Why bother? Because downstream consumers react to history (chapter 5 and chapter 8): a projection can fold ExportCompleted records into a "last exported 2 min ago" indicator without that clutter ever living in Schematic.

When you're unsure which to write, ask one question:

If replay skipped this entry, would the rebuilt state be wrong? Yes → transaction. No → record.

Transactions are for the state; records are for the story. This isn't just taxonomy — it has teeth later: an entry's kind is stamped into storage, which is what lets a runtime make safe decisions about entries it doesn't recognize (a skippable unknown fact vs. an unskippable unknown change — chapter 10).

Under the Hood: What the Macro Actually Does

Section titled “Under the Hood: What the Macro Actually Does”

#[harmos::transaction(...)] generates durable identity, and nothing else. The string "symbol.move" is the DefinitionId — the permanent wire name stamped into every stored entry at encode time. It, not the Rust name, is what history remembers; rename MoveSymbol to RelocateSymbol next year and every old file still opens, because the wire name never changed. version = 1 sits beside it, dormant until chapter 10.

Concretely, the attribute adds two constants and one function to your type:

MoveSymbol::DEFINITION // (Kind::Transaction, "symbol.move", 1)
MoveSymbol::HISTORY // every earlier version, empty until chapter 10
MoveSymbol::restore(..) // reads one stored payload as the version that wrote it

DEFINITION is what the registration list at assembly (chapter 3) is built out of, and restore is what a storage adapter reaches through when a stored entry names this definition. Your check and apply are untouched — no codegen rewrites your logic.

The name is exact on purpose. A DefinitionId identifies a kind of entry, the way a class names a kind of object. The identity of one particular entry in history is its Position (chapter 4). Two ids, two questions: which kind of thing, and which one in history.

All of that has one consequence: your entire domain layer is plain Rust functions over plain data.

#[test]
fn moving_an_unplaced_symbol_is_refused() {
let change = MoveSymbol {
symbol: SymbolId(7),
to: Point::new(10, 20),
};
assert_eq!(
change.check(&Schematic::default()),
Err(SchematicError::UnknownSymbol(SymbolId(7)))
);
}

No runtime started, no tokio, no mocks, no test harness. cargo test.

1. Product request: “remember each user's preferred grid spacing across restarts.” Transaction, record, or neither — and where does it live?

Neither. A per-user preference is durable-but-personal: home number three from chapter 1, an ordinary config file the UI layer owns. Zero harmos involvement.

Nothing about the schematic changes when a user prefers a 0.5 mm grid, so nothing about it belongs in the document's history.

2. You're writing ConnectPins::apply and realize mid-keystroke that the target net might not exist. What does that realization tell you, and where does the code you're about to write actually belong?

It tells you a check condition is missing. The code you are reaching for is a check condition wearing an apply disguise — move it, and give it a typed error of its own.

Once the check exists, finish apply on the assumption that it passed: schematic.net_mut(self.net) may presume presence. Any hedging you leave behind in apply is either dead code or a second, quieter validation path that replay will run and commit will not.

3. Version 2 tightens the rules: check now forbids two symbols with the same reference designator. A customer's file — written under v1 — contains a duplicate. What happens when v2 opens that file, and why is that correct?

The file opens fully, and the rebuilt state contains the duplicate.

Each stored entry decodes by its stamped (DefinitionId, version); replay dispatches to that stored version's implementation and calls only apply. The duplicate-creating entries apply exactly as they did the day they were committed. No check is consulted — not v1's, not v2's.

That is correct because the entry was already accepted, under the rules that existed then. v2's stricter rule governs what may be committed from now on. The customer's duplicate is cleaned up by a new transaction, if anyone wants it cleaned up at all.

4. Why is the id called DefinitionId and not EntryId or EntryDefinitionId?

EntryId would name the one thing it isn't. The id of a particular entry is its Position; DefinitionId names the kind, the class rather than the instance.

EntryDefinitionId fails differently: definitions are bigger than entries. Jobs, streams, and artifact formats carry DefinitionIds too, so the Entry prefix would either lie or force a family of parallel names for the same idea.

DefinitionId = which kind of thing. Position = which one in history.

5. In your own words: why would running check during replay brick old files? Give the failure mechanism, not the rule.

Suppose replay did run check, and an entry that was valid in v1 fails v2's tightened rule halfway through a rebuild. There are exactly two things the runtime can do, and both are corruption.

It can abort the load — the customer's file, which their business depends on, now refuses to open because you shipped a stricter rule. Or it can skip the entry and continue, which is worse: a transaction cannot be skipped, because every later entry was committed against the state that transaction produced. Skipping it silently builds a state that never existed and that the remaining entries were never checked against.

A stricter validation rule is a decision about the future. Applying it backwards turns a shipped rule change into a data-loss event.

Stokker Technologies markDesigned and built by Stokker Technologies