Append-only truth: event ledgers, self-loop transitions, and the admin override

Engineering

Append-only truth: event ledgers, self-loop transitions, and the admin override

FastYoke Engineering · 8 min read · Aug 17, 2026

  • Architecture
  • Audit
  • FSM

The problem

Here is a question every operations team eventually has to answer, usually under pressure: what actually happened to this record? A shipment that shouldn't have been marked delivered. An invoice that went from draft to paid without anyone recalling how. A job someone force-cancelled last quarter, and now a customer wants to know who did it and why.

If your system stores state the way most systems do — a status column that gets overwritten every time something changes — that question has no answer. It has, at best, an argument. You can look at the current value and a scatter of timestamps on unrelated tables and infer a plausible history, but the record itself only ever tells you one thing: where it is right now. Every UPDATE status = 'delivered' silently destroyed the fact that came before it. The prior state didn't move to an archive. It was overwritten in place and is simply gone.

That is the difference between a system that can tell you the truth about its past and one that can only show you its present. And for business software — where "what happened" is frequently the entire question a regulator, an auditor, or an angry customer is asking — the present is not enough.

Why a mutable status column loses history

The failure is structural, not a matter of discipline. A single mutable column has room for exactly one value. When you write the new state, the old one has nowhere to go. You can bolt on an updated_at timestamp, but that tells you when the last change happened, not what it was or what preceded it. You can add a last_modified_by, but it records one actor — the most recent — and forgets everyone before them.

Teams patch around this with triggers that copy old rows into a history table, or with an application-layer "activity log" that developers have to remember to write to. Both are better than nothing and both leak. The trigger fires only for the paths that go through the database the way you expected; the hand-written log gets skipped the one time someone adds a new code path in a hurry. History that depends on remembering to record history is history you will eventually be missing exactly when it matters.

The fix is to stop treating "the current state" and "the record of how we got here" as the same thing. They are two different concerns that a status column conflates. FastYoke splits them, and makes one of them append-only.

How FastYoke approaches it

Every state change in FastYoke does two durable things. It updates the job's current state — the fast, indexed answer to "where is this now" — and it appends an immutable row to an event log. That log is never UPDATEd and never pruned. The engine physically cannot make a state change without producing the record of it, because the two writes are the same operation. You don't have to remember to log; there is no code path that transitions a job and forgets to.

Because the log only ever grows, "what happened to this record?" stops being an argument and becomes a query. You SELECT the rows for that job, in order, and read its entire life: every transition, when it fired, and what drove it. Nothing in that history could have been edited after the fact, because the system has no statement that edits it. That's the property auditors and compliance teams actually want to validate — not a log you promise is complete, but one that is structurally incapable of having been quietly rewritten. We've written about why an explicit state machine is the right foundation for this in finite state machines are the right abstraction; the append-only ledger is what falls out the other side of modeling transitions as data.

Self-loops: recording that something happened

Once the log is the source of truth for history, a subtle need appears. Plenty of things happen to a record that don't move it forward. A driver checks in. A note gets attached. A webhook needs a retry while you wait on a condition that hasn't been met yet. None of these change the job's lifecycle position — but all of them are events you want in the ledger.

The clean way to represent this is a self-loop transition: a transition whose from and to are the same state. It is a first-class construct, not a hack. The engine fires its guard, writes the state (with the same value it already had), appends an event-log row, and broadcasts the change to live clients — identically to a forward-moving edge. The only visible artifact is the new row in the ledger, which is exactly the point. The state machine can now say "something happened here" as distinctly from "something moved this record forward," instead of overloading the status column to mean both.

Reach for a self-loop when you need an audit-only event (a check-in, a note), an idempotent retry of a side effect while waiting on a forward-progress guard, a counter or accumulator increment where the state is deliberately fixed, or a shared event name that has a forward edge plus a self-loop fall-through. In every case you get the durable record without faking lifecycle movement the record didn't actually make.

The admin override: a cancel that stays honest

There is one thing FastYoke deliberately does not model as a normal transition: cancellation. There is no "Cancelled" state wired into the workflow graph for an operator to route into. That is a design decision, and it is worth explaining why.

The temptation is to add a Cancelled state and draw edges into it from everywhere, so an operator can always escape a stuck job. Do that and you have polluted the graph — every state now needs a cancel edge, guards multiply, and worse, you have created a legitimate-looking transition that bypasses the very business rules the rest of the graph exists to enforce. Modeling every escape hatch as a transition invites exactly the guard-bypass hacks a state machine is supposed to prevent.

So FastYoke keeps the escape hatch, but makes it a visibly different mechanism. Force-cancelling a job is an out-of-band, admin-only override. It writes the job's current state directly to a target terminal state and bypasses guard evaluation entirely — it never invokes the transition engine and never asks a guard for permission, because "break glass" is precisely the case where the normal rules don't apply. Crucially, it does not get to be silent about it. The override appends a row to the same append-only event log, recording the operator's identity as the actor and a mandatory, non-empty reason. It does not UPDATE the event log — it adds to it, like everything else. A live broadcast fires so every connected client reflects the override immediately.

The result is an escape hatch that is powerful and still fully attributable. Every override answers who forced it and why, in the same ledger as every ordinary transition, and those two facts — actor and reason — are the sole audit record of the action. They are treated as load-bearing: they are never dropped or nulled. An override can move a job anywhere, but it can never move it quietly. That is the property that lets you keep a genuine administrative override without it becoming the hole in your audit story. It's the same instinct behind everything in our enterprise posture: power that isn't accountable isn't a feature, it's a liability.

What to watch out for

Append-only is not free, and pretending it is will bite you.

The honest tension is that the log only ever grows. That's the source of its integrity and also its cost. An unbounded self-loop — an auto-retry with no cap, a counter that fires on every poll — turns directly into an unbounded ledger. The fix is the same guard mechanism that governs every other transition: bound the loop in its own guard (retry_count < 3 as an illustrative predicate), or rate-limit it at the scheduler. Don't assume an auto-fired self-loop will stop on its own; make the guard the thing that stops it. And because you can't DELETE your way out of a growing table, you plan for scale deliberately — retention windows, periodic rollups into summary rows, cold storage for old ledgers — rather than reaching for the delete that append-only forbids.

The second trap is a UI one, and it surprises people. A self-loop doesn't change the state column, so any surface that only renders current_state will show a self-loop as a no-op — nothing appeared to happen, even though a row was durably written. Optimistic UI makes this worse, because the client's immediate update also sees no state change to apply. The rule is simple: any surface that should reflect a self-loop must read the event log (or a counter derived from it), not the state column alone. The state column answers "where is this"; the ledger answers "what has happened to it," and those are now genuinely different questions your UI has to ask separately.

Where this goes next

An append-only event ledger is not a logging convenience bolted onto the side of the workflow engine. It is the thing that lets a system make claims about its own past that will hold up when someone checks — and it changes what the rest of the architecture can promise. State transitions become provable, not merely plausible. Administrative overrides become attributable instead of invisible. And "something happened that didn't move the record" becomes a distinct, first-class fact rather than an abuse of the status column.

If you want the foundation this sits on, start with why finite state machines are the right abstraction for business software. If your interest is the audit and sovereignty story end to end — mechanical tenant isolation, the ledger as a procurement artifact, deployment from managed cloud to air-gap — that's the enterprise and security posture. And if you're building on top of this, FastYoke for developers is where the workflow engine, guards, and event log meet your code.