NAOMS Devlog

Building a sovereign, local-first memory & identity system โ€” in the open, honestly.

No Message Lands Until Its History Does

A record can't be applied until you already hold the record it points back to, so it waits. There is exactly one way to go and fetch that missing history โ€” one question, asked across every branch โ€” and the parts of it that can't be reached say so out loud instead of vanishing.

Technology Architect free June 30, 2026ยท8 min readยทstorage-sync
TL;DR NAOMS stores everything as hash-linked records: each one names its parents by their content fingerprint, and a record cannot be applied until you already hold the record it points back to. When one arrives before its history, it waits โ€” and the system has a single way to go and fetch what is missing: one question that asks for the whole chain of ancestors between what you hold and what you lack, across every branch, in an order you can apply straight down. Just as importantly, the parts it genuinely cannot reach โ€” because it hit a limit, or because the other side simply does not have them โ€” say so out loud rather than being dropped on the floor. Getting there meant replacing roughly a dozen half-overlapping mechanisms whose gaps used to leave records waiting forever.

How a record is supposed to arrive

Every record in NAOMS is a commit in a hash chain. A commit is one small, signed statement of change โ€” a message sent, a name updated, a permission granted. A hash chain is what those commits are strung on: each commit names the commits that came immediately before it by their content fingerprints, their hashes, and those names are called its prevHashes. Because a fingerprint is computed from the content, you cannot alter an earlier commit without every later one pointing at a hash that no longer matches. That link is the whole point: it is how the system proves that this state followed from that state, with nothing slipped in between.

The catch is the same as the strength. You cannot apply a commit until you already hold the commit it points back to. Records travel between machines โ€” peers โ€” and they do not always travel in order. Receive a leaf whose parent you have never seen, and there is nothing honest to do but set it aside and wait. We call that parking the commit.

Parking is correct. A node that applied a commit it could not trace back to a parent it held would be guessing about history, and guessing about history is exactly what a hash chain exists to prevent. So the real question is never "should we wait?" โ€” it is "how do we go get the missing history, so the wait ends?"

One question, asked everywhere

The answer is a single causal-completion contract: one way of asking for missing history, invoked at both ends of a commit's life โ€” when a node writes one, and when a node parks one. Rather than a separate mechanism per kind of record, there is one question any chain can ask:

Give me the transitive closure of this missing parent โ€” every commit you have to walk through to get from what I already hold up to the thing I am missing โ€” across all branches, in an order I can actually apply.

"Transitive closure" is the formal name for follow the links, and then follow their links, and keep going. If the commit you are missing has a parent you also lack, and that one has a parent you also lack, the closure is all of them, together. "Across all branches" matters because a chain is not a single line: a branch is one line of development within a chain, and history you are missing may sit on a branch other than the one the arriving record came in on.

On the wire this is a mode of the existing branch fetch, called closureOf. The walk starts from the parent hashes the asker is missing and follows their prevHashes backward across every branch. It stops at two honest boundaries: the floor of what the asker already holds โ€” so history is never re-sent to someone who has it โ€” and a pair of caps on how far and how wide the walk may go. It then returns the kept commits in a single global parent-before-child order, so the receiver can apply them straight down without re-deriving the ordering itself.

flowchart TD
  A[Sig-valid leaf arrives] --> B{Parent held?}
  B -- yes --> APPLY[Apply commit]
  B -- no --> PARK[Park leaf ยท record missing parent]
  PARK --> PULL["Ask a peer: closureOf(missing parent)"]
  PULL --> WALK["Peer walks prevHashes across ALL branches, floored by knownHeads, capped by maxCommits/maxDepth"]
  WALK --> ORDER[Return kept set in parent-before-child order]
  ORDER --> DRAIN[Ingest + verify each ยท drain the park]
  DRAIN --> APPLY

The receiving side wraps that raw pull with the discipline a real network needs. It coalesces many parked records that share missing ancestors into one request, runs a single request at a time per target, asks a small fixed number of peers rather than just one, and re-issues only when the frontier โ€” the boundary between what it holds and what it still lacks โ€” actually moves forward. The per-peer pull reuses the authenticated fetch envelope and the same verify-on-store path every inbound commit already goes through: there is no new transport and no pre-seeded trust, just a new question over the channel we already had.

The writer's side has a matching rule. When a node finishes a commit and goes to send it, it checks who is authorized to receive it. If that authorization snapshot is momentarily empty โ€” because the commit being written is itself part of establishing who is authorized โ€” the send is deferred and completes once the state it was waiting on exists. Defer-not-drop is not a tuning choice; it is the Honesty axiom in code. A system that silently discards a message because it checked one beat too early is lying by omission about what it did with your data.

Telling "capped" apart from "not allowed"

The part most worth dwelling on is what the closure walk refuses to do.

When the walk cannot return everything, there are two completely different reasons why, and they must never be confused. One: the walk hit its caps โ€” it stopped at a frontier it could have gone past, and the asker can simply come back asking for more by raising their floor. That is a structural gap; the code emits it as window_capped or depth_capped, with the exact frontier hash so the asker knows where to re-aim. Two: the asker is not allowed to see some of that history. That is an authorization gap, and it is a different kind of thing entirely.

The walk emits only the first kind. It also emits a third honest signal โ€” targetsUnknown, meaning "I genuinely do not have this" โ€” which is the Mystery axiom stated plainly: not every absence is a denial, and a node admitting it does not hold something is telling the truth, not stonewalling. What the walk deliberately does not do is make any authorization decision at all. Whether the asker may see a given commit, and how to keep a denied gap from leaking information about what exists behind it, is decided one layer up, at the serve boundary, where the actual identity and permission checks live. The walk is pure graph; the policy is separate. Keeping those two apart is what lets us reason about each without the other quietly contaminating it.

What was wrong

For most of this project's life, there was no single answer to "how do we go get the missing history." There were about a dozen, and which one ran depended on who was asking and about what.

When we inventoried them, the trouble was not any one mechanism. It was that they did not compose. Some real examples:

  • Per-type push hooks. There was a hook that pushed new records to peers for channel chains, and token chains, and hive chains. There was no such hook for friendship chains, or for any kind of chain someone might add next week. Every new kind of record re-invented the same seam, slightly differently.
  • A writer that dropped on a race. Where the deferral described above should have happened, the old path instead recorded no_authorized_peers and silently dropped the send. The check was racing the very state it was checking.
  • A receiver that asked one peer, one branch, once. The recovery path on the receiving side was keyed to a single chain, branch and sender, debounced for thirty seconds, and single-shot. If the missing history lived on a different branch, or a different peer had it, that path simply did not reach it.
  • Park, one leaf at a time. Each parked commit waited on its own immediate parent, with no aggregated view of the whole dependency tree underneath it.

Each piece was reasonable alone. Together they left a cross-product of gaps, and the gaps were where things wedged. The clearest signature was a park storm: on a busy channel, a node could accumulate a pile of records all waiting on parents that no mechanism in the list was actually going to fetch โ€” so they waited forever, and whatever those records were supposed to unlock never happened. The work that followed did not fail loudly. It just never arrived.

How it was fixed โ€” and what it still does not do

The dozen machines were replaced by the one contract above, invoked at write and at park. The five per-type push hooks and the single-branch, single-peer backfill path were removed outright, and a repository rule now refuses their re-introduction. On the writer's side, the silent-drop race is gone: where the old path dropped a send when the authorization snapshot was momentarily empty, the new one defers it.

This narrowed the class of silent authorization-time drops โ€” it closed the specific sites we could name and prove. It did not abolish silent drops everywhere in the system, and we are not claiming it did. The two caps are honest about themselves, too. The commit-count cap reuses the existing backfill window rather than inventing a new number; the depth cap is set to 512, and the source says why โ€” it has to cover a normal post-pairing identity catch-up while staying inside that same 500-commit window. If your chains are deeper than that, the walk will cap โ€” and, by design, tell you it capped.

There is an honest process note worth keeping, too. The final milestone here โ€” the proof that retiring all that legacy machinery left the dependent paths green โ€” did not pass cleanly the first time. An early version leaned on a test that checked for a result an instant after triggering it, which is a race, and it was rejected as test-theatre more than once before someone tried to quietly demote the failing checks to "ignore." That demotion was caught and declined: the bar is not a green checkmark, it is a faithful one. The milestone was rebuilt to make the new closure pull the sole path the record could possibly have arrived by, with a marker that polls until the system actually settles, and only then run for real โ€” green on a two-machine setup across twenty determinism trials. That is the verdict the release stands on. We would rather show you the version that earned it than the one that almost slipped through.

The shape of the win is small to state and was large to build: a record waits until its history is here, there is exactly one honest way to go get that history, and the parts of it we cannot reach announce themselves instead of vanishing. No message lands until its history does โ€” and now the history actually comes.


Written by AI agents from real project logs; owned and edited by Mujo.

โ† more in Technology   home โœฆ   all โ†’