The operation came before the record
X8B6 stopped treating an editable inventory row as the truth and began preserving immutable actions from which local and shared projections could be explained.
X8B6 began as a table of inventory records. Each row had an item, quantity, location, and updatedAt. Editing a row replaced the object locally and later replaced it on the server.
The model was easy to render and hard to reconcile.
When the same item changed in two places, the application had two records and no account of why they differed. A quantity of ten might mean two units were removed, a physical recount found ten, or a mistyped twelve was corrected. The final field concealed the work that produced it.
I changed the center of the model from record to operation. The current row became a projection; the action became the durable fact the system moved between devices.
A record described the result, not the intention
The original update payload looked ordinary:
{
"itemId": "lamp-14",
"quantity": 10,
"updatedAt": "2014-03-04T15:18:00Z"
}It answered what value one device wanted to see. It did not answer which previous state the person saw, whether the change was relative or absolute, or whether resending the payload should create another effect.
The new operation said more:
{
"operationId": "op_8f21...",
"itemId": "lamp-14",
"kind": "adjust",
"delta": -2,
"baseRevision": 83,
"createdAt": "2014-03-04T15:18:00Z"
}The projection could still become quantity ten. The operation preserved how it got there. If another -1 adjustment had already reached the server, both actions could be represented rather than one row winning by timestamp.
The extra fields were not audit decoration. They determined retry, conflict, and explanation behavior.
The browser generated operationId when the user committed the action, before writing to local storage and before sending a request. The identifier stayed with the intent through every retry.
Generating an identity on the server would be too late. If a request timed out after the server applied it, the browser would have no stable name with which to ask for the existing result. Generating a new identifier on retry would turn uncertainty into a duplicate action.
The identifier did not need to encode business meaning or time order. It needed to be sufficiently unique within the operation namespace and stable for the lifetime of the action. Human-readable sequence remained separate.
I treated identity as immutable. Editing an unsent draft before commit did not create an operation. Changing a committed operation created a compensating or superseding operation rather than rewriting the identity's payload.
This made a server rule possible:
One operation identity corresponds to one declared intent and one durable result.
If the same identity arrived with different content, the server rejected it as a protocol error. It did not guess which version was newest.
Immutable did not mean infallible
Calling operations immutable risked making mistakes feel permanent. A person could enter -20 instead of -2. The app still needed correction.
If the operation had not been submitted, the user could discard it and create the intended one. Once accepted, correction became another operation with a link to the original:
op A: adjust −20, confirmed at revision 84
op B: correct op A with +18, confirmed at revision 85The current quantity reflected both. History could explain that B corrected A. The original receipt remained true about what the system had done.
This was more verbose than changing the old row from -20 to -2. It preserved the sequence that downstream views and devices may already have observed. Mutating history would create different realities depending on when a client synchronized.
Immutability was a rule about published facts, not a refusal to help people recover. The interface made correction a first-class action and showed the resulting quantity before confirmation.
The model accepted human fallibility while keeping effects accountable.
The first operation type was a generic update with arbitrary changed fields. That recreated record replacement inside an envelope.
I split inventory intent into a small vocabulary:
adjust: add or subtract a counted movement.recount: declare a physical count observed at a time.move: transfer a quantity between named locations.annotate: attach context without changing quantity.correct: explicitly compensate for a prior operation.
The types were not chosen to mimic a universal inventory system. They reflected actions X8B6 needed to reconcile differently.
Two adjustments could usually compose. A recount based on revision 83 might need review if several later movements already existed. A move needed both source and destination to commit together; applying only one side would manufacture or destroy stock. An annotation had no quantity conflict but still required a valid item.
The operation kind selected validation and conflict policy on the server. It also selected the interface used to explain a problem. “Another change occurred” was inadequate when the next step differed by verb.
Strong operation names reduced generic merge code and made domain consequences visible.
The record became a projection with a revision
The current item row remained useful for rendering and querying. Recomputing every quantity from the beginning of history on each page load would be wasteful and unnecessary.
The server maintained a projection through a named revision:
type ItemProjection = {
itemId: string
quantityByLocation: Record<string, number>
throughRevision: number
lastOperationId: string
}Applying a confirmed operation advanced the projection and stored a receipt in one transaction. The receipt named the operation identity, resulting revision, and relevant result.
The browser stored a confirmed projection plus its pending operations. Its optimistic view was derived by applying eligible local operations in order. That meant it could discard and rebuild the optimistic layer without losing intent.
A projection bug was serious but recoverable. The operation log and receipts could reveal how the value was produced. A mutable-record design had no independent history against which to check the current row.
The record did not disappear. It lost its claim to being the only durable representation of work.
Projection functions needed to be deterministic
Given the same confirmed projection and ordered operations, X8B6 needed the same next projection. A transition could not depend on the current wall clock, random identifiers generated midway, or data fetched from an unrelated service.
Validation that required external context happened before commit and its result became part of the receipt. The projection function operated on declared inputs.
function applyOperation(
projection: ItemProjection,
operation: AcceptedOperation,
): ItemProjection {
// No I/O, no current time, no hidden global state.
}This made rebuilding and testing possible. A fixture could begin at revision 83, apply an adjustment and correction, and assert both quantity and revision 85.
Determinism did not guarantee the business policy was right. It guaranteed that the policy's result could be reproduced and reviewed. If a rule changed, the system needed an explicit migration or new operation semantics rather than silently replaying old actions under new meaning.
I versioned operation payloads where semantics might evolve. The version was part of the accepted record, not inferred from deployment date.
Order was local before it was global
An offline device could create operations A, B, and C in that sequence. Creation time was useful for display and not a reliable global ordering source. Device clocks could drift, and another device had its own sequence.
The browser assigned a local sequence within its outbox. Dependent operations preserved that order when sent. The server assigned a confirmation revision when it accepted each operation.
device order: A1, A2, A3
server confirmation: revision 84, 86, 87Another device's operation could become revision 85 between A1 and A2. That did not violate A's local intent; it created a concurrency case the operation policies had to handle.
I did not sort all work by createdAt and pretend it formed a trustworthy global chronology. The server receipt time described when shared state incorporated the action. The local creation order described one device's intended sequence.
Where one operation depended directly on another—such as correcting an unconfirmed action—it carried the dependency identity. Order alone was too implicit for important relationships.
The model retained multiple honest partial orders instead of forcing every event onto one device clock.
Dependent operations needed a pause boundary
Suppose an offline device recorded “move two lamps from shelf A to B” and then “remove one lamp from shelf B.” If the move conflicted, sending the removal independently might apply it to a shared state where the lamps never arrived.
The outbox represented dependencies. A failed or review-required operation paused descendants that relied on its projected result. Unrelated work continued.
I kept the dependency graph deliberately constrained. Most operations depended on the latest pending operation for the same item and location. The app did not expose an arbitrary graph editor. The internal relationship was enough to prevent unsafe reordering.
The review interface showed the chain in task language:
Move 2 to shelf B — needs review
Remove 1 from shelf B — waiting for the move aboveIt did not mark the second operation failed. Its outcome was not yet attempted because its premise remained unresolved.
This was another advantage of operations over records. The system could preserve how one intention was built on another. A list of final row versions would only show competing quantities.
A receipt completed the operation
The server response originally returned the updated item record. That let the browser render and did not give it a durable proof tied to the action.
The accepted response became a receipt:
type OperationReceipt = {
operationId: string
outcome: 'accepted' | 'rejected' | 'needs-review'
confirmedRevision: number | null
result: OperationResult
recordedAt: string
}The server stored the receipt under the operation identity. A retry returned the same semantic result. The browser committed the receipt locally before removing the operation from its pending set.
If the tab closed after receiving the HTTP response but before updating local state, startup could ask the server for the receipt and finish reconciliation. The response was not an ephemeral success toast; it was part of the protocol.
Rejected results also persisted. Retrying the same invalid operation did not repeatedly run validation and potentially produce a different explanation under changed rules. A person could create a corrected new operation.
The receipt turned “did it work?” into a queryable relationship instead of a memory of a callback.
A record-replacement endpoint validated the submitted row in isolation. An operation endpoint could validate the intended transition against current confirmed state.
For an adjustment, it checked item existence, quantity constraints, permissions, and any rule against negative stock. For a move, it checked both locations and committed both projection changes atomically. For a recount, it compared the declared base revision and returned intervening context when review was required.
The server did not accept the browser's optimistic projected quantity as authority. It applied the operation to its own confirmed projection. The client projection was an interface aid and a preview of expected outcome.
This prevented a corrupted local cache from becoming a replacement payload. An attacker or bug could still submit an invalid operation; validation rejected it according to the declared verb.
The boundary became narrower. The server needed to understand a limited set of operations rather than every arbitrary combination of mutable fields.
That constraint made compatibility work more deliberate. Adding a new field to the display did not automatically expand the mutation protocol.
History became useful without becoming the main view
An operation log can seduce a product into exposing an event stream everywhere. X8B6's primary task still needed a clear current quantity.
The main table used the projection. An item detail offered a human history derived from operations and receipts:
14:32 Removed 2 from shelf A · confirmed
14:41 Moved 3 from shelf A to shelf B · confirmed
14:49 Counted 8 on shelf A · needs reviewThe display translated payloads into domain language and retained stable identifiers in detail. It distinguished local pending time from server confirmation time. Corrections linked to what they corrected.
I avoided dumping JSON or exposing revision numbers as the conflict explanation. Revisions were diagnostic evidence. The product language centered on the work, subject, and outcome.
The history earned its place because it could answer why the current record had its value and which intentions had not yet joined shared state.
The operation model supported that answer; the UI still needed to edit it for people.
Compaction preserved receipts and boundaries
Keeping every operation forever in every device was unnecessary. Confirmed history could be compacted into a snapshot through a named revision, while the server retained receipts for the idempotency window and the archive retained whatever the project policy required.
The browser never compacted pending or review-required operations. It could replace confirmed projection data only when a newer server snapshot declared the revision it covered.
A compaction record said:
snapshot through revision 1200
pending local operations begin after local base 1200The client rebuilt its optimistic view by applying pending operations to that snapshot. It did not need the previous thousand confirmed operations for ordinary work.
Receipts had a different retention obligation. If a device might retry an operation after being offline for weeks, expiring the receipt sooner could allow a duplicate effect. The policy either retained identity results long enough or rejected operations older than the safe window for review.
Storage management followed protocol meaning. “Old” was not by itself permission to delete.
Operations solved ambiguity about intent and retry. They did not automatically solve every distributed problem.
A wrong domain verb could encode the wrong policy consistently. An accepted adjust -2 could still be based on a mistaken physical count. Long offline chains could become difficult to review. Server projection bugs could affect every subsequent result until detected. Device identity and author authentication still mattered.
The project avoided claiming a complete event-sourced architecture. It used immutable operations and receipts where interruption and reconciliation required them, plus snapshots and projections for practical reads.
This restraint helped. I did not add a generic event bus, rebuild every view from an infinite log, or force unrelated preferences through the operation protocol. The design centered on inventory actions whose meaning needed to survive offline execution.
Naming the limited problem kept the system understandable enough to test.
The new model also corrected a small reporting error. The first interface counted every submit callback as an inventory change. Retries inflated activity, and a rejected update could look like completed work.
Reports began counting unique confirmed operation identities by kind. Retry attempts remained diagnostic transport data. Review-required operations appeared as unresolved work, not as successful adjustments. Corrections could be reported separately without deleting the original effect from history.
This mattered even in a personal project because numbers teach developers what the system believes. A metric based on requests would reward noisy retry behavior. A metric based on receipts described durable outcomes.
The distinction followed directly from making operation identity first-class: attempts could be many, intent could be one, and the accepted result could be queried.
The record was still valuable, just no longer alone
X8B6 still rendered rows, queried quantities, sorted locations, and exported current projections. Records were excellent read models.
The mistake had been asking the record to double as the only description of work. A final quantity could not safely replay an adjustment, explain a conflict, or distinguish a correction from a recount.
Once the operation came first, several later decisions followed. Stable identity made retry safe. Explicit verbs carried conflict policy. Receipts made outcomes queryable. Projections could be rebuilt. Pending chains could pause without blocking unrelated work. History could explain the current value.
The change also sharpened the interface. It could say “remove two waiting to send” rather than “record modified.” It could show what another action changed instead of reporting a version mismatch.
The current record remained the fastest answer to “what is the quantity now?” The operation log answered “how did we get here, what is still pending, and what can be repeated safely?”
X8B6 needed both. It became trustworthy only when it stopped pretending they were the same object.