
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.