Declare what a record's origin stamp reveals
A feature declares what the origin stamp on its rows may say โ and when a row kind declares nothing, a known-dangerous marker makes the filing engine withhold the signer anyway
The stamp that says where a row came from
When this system files a record, it writes down a few facts the record never said about itself: which log it came from, which entry it was, and who signed that entry. Call that third one the origin stamp.
It is not decoration, and it is not private. Downstream code reads the origin stamp to decide whether a caller may read a row that arrived from someone else, whether a peer sending an update is the one who owns it, and what goes into a data export โ and anyone holding a copy of that database, or of an export file taken from it, reads it too. That is the audience to keep in mind for everything below: not a network attacker, but whoever can see a row. For the several hundred kinds of row the system already had, the signer of an entry genuinely is the row's owner, and stamping it is the right answer.
There is a category of row where it is the wrong answer, and the reason is structural rather than accidental.
(The filing engine sits between an append-only log of signed entries and an ordinary local database rebuilt from it โ we have written about that split before, and about how the engine files a kind of record nobody taught it.)
Where the signer is the wrong answer
A being in NAOMS โ a person or an agent with an identity of its own โ has a decentralised identifier, a name that resolves without any company's server standing behind it, because the name is derived from a key its holder controls. Call the primary one the master identifier. It is the thing that, once someone has it, joins everything you do under one heading.
Which is why a system that expects you to appear in several contexts needs pseudonyms: additional identifiers, each derived for one particular context โ a shared group you belong to, say โ that other people in that context can address you by. You in one shared group, you in another, so that joining the two up is work an outsider has to do rather than a lookup the system hands them.
For a pseudonym to be usable locally you must be able to find your own again, so the system keeps a small mapping record pairing the pseudonym, in the clear, with a hashed form of the master identifier that the owner's own device can recompute and match. That record is the whole point of the exercise.
And it is written to your own log, and creating it takes the master identifier's authority โ so the entry that creates it is signed by the master identifier. A general rule that stamps the signer onto every row rebuilt from the log would therefore file that record with your master identifier written out in plain text, one column away from the pseudonym it exists to keep apart from you โ no guessing required. The reason has nothing to do with the feature and everything to do with the generality of the engine.
A general engine cannot infer this. It does not know what a pseudonym is, and it should not have to. The information has to come from somewhere else.
Declare it, do not remember it
A feature in NAOMS ships a small declaration of what it is: the kinds of entry it emits, the kinds of row those project to, which of them are spoken for by a specific handler. As of this week that declaration carries one more field โ the row kind's provenance class, provenance being where a row came from, one of three values:
- public โ stamp the signer. That is also what an undeclared kind gets, unless it trips the default's tripwire described in the next section; declaring it is still not the same as leaving it off, because an explicit declaration is taken at its word and skips that tripwire entirely.
- private โ write no origin stamp at all. The field is removed rather than set to an empty value, so a reader sees an absent field instead of a blank one it has to interpret. Removing it takes an answer away rather than granting one: an absent stamp is nothing for a check to match against.
- pseudonymous โ stamp a derived, per-context identifier instead of the signer. This one has a condition attached, and it gets its own section below.
The class is decided in one place โ a single function the engine calls as it writes the stamp โ rather than a special case per row kind scattered through the filing code. Deciding it in one place is the whole design: a privacy rule that lives in one place can be read in one place, and a rule you have to remember is a rule that eventually is not remembered. The declarations themselves are collected as features load at startup.
flowchart TD
A["A row is about to be filed"] --> B{"Did the feature declare a
provenance class for this row kind?"}
B -- "public" --> C["Stamp the signer's identifier"]
B -- "private" --> D["Write no origin stamp at all"]
B -- "pseudonymous" --> E{"Is this the being's own
local identity log?"}
E -- "yes" --> F["Stamp a derived, per-context identifier"]
E -- "no โ a shared log" --> G["Refuse, loudly.
A derived value here is checkable
by anyone who can guess the input."]
B -- "nothing declared" --> H{"Do this row's fields include a hashed
master identifier, the identifier of
whoever holds what the row is about,
or a signing-key id marked as
the master's main key?"}
H -- "yes" --> D
H -- "no" --> C
Two details in how this is wired are worth borrowing. The first is what happens when two features disagree about the same row kind: the first declaration recorded stands, with one exception in the safe direction โ a declaration that restricts the stamp displaces an earlier one that did not.
The second is that the stamp is resolved a second time, after the engine merges a new write into a row that already exists, because that merge re-overlays the row's existing properties and could otherwise carry an older stamp back in. A rule applied only on first write is not a rule about the row; it is a rule about the first write. That is also what it means for the database to be a projection rather than the record itself: a row can be filed again under the current rule rather than frozen with the answer it got the first time.
All of this is a rule about the rebuilt database, not about the log it is rebuilt from: the entry is still signed, and the signature still names the signer, to whoever holds a copy of that log.
The default is the part that ships
A declaration only helps the kinds that carry one, and the kind of a row is derived from the entry's own payload โ so it can be new, or misspelled, or written by a feature that never thought about any of this. What happens then is the part of this design that actually runs most of the time, and it is worth stating precisely rather than generously.
An undeclared row kind keeps today's behaviour and gets the signer stamped โ unless the row's own fields carry one of three markers: a hashed master identifier (the same field the mapping record carries), the identifier of whoever holds the thing the row is about (holding something is itself a linkable fact), or a signing key id marked as the master's own key. Any of those, with nothing declared, and the engine withholds the signer. That is the fail-closed part: on those three shapes it withholds rather than reveals.
And it is worth being exact about how narrow that is. It is a fixed list of two field names plus one suffix test โ not an analysis of whether a row is sensitive. A new kind of record that is genuinely private but whose fields happen not to look like any of those three will be stamped with the signer, because nothing told the engine otherwise. The protection you get by default is a tripwire on the shapes that were known to be dangerous. The protection you get by declaring is the real one.
There is a matching check in the build. A feature's declaration file that names one of the known sensitive row kinds and marks it public โ or leaves the class off โ fails the build rather than shipping. The check knows those kinds by name, from a list maintained by hand, and its own documentation explains why it cannot do better: a declaration names a row kind, not the row's fields, so a static check has no way to infer sensitivity. It is a backstop against explicitly writing down the wrong answer, not a detector of privacy problems.
And the honest scale, today: exactly one row kind in the whole system declares a provenance class, the pseudonym mapping record, and it declares itself private. The vocabulary now exists for any feature that needs it, and one feature needs it so far.
Why the pseudonymous class refuses on a shared log
The third class is the interesting one, because it ships with a refusal attached. Nothing declares it yet, so what follows is a rule waiting for its first user rather than a description of something firing today.
The per-context identifier is computed by hashing the master identifier together with the identifiers of the context. The hash is BLAKE3, and it is unkeyed: a general-purpose hash built for speed, with no secret in the computation โ and speed is exactly what makes grading a long list of guesses cheap.
Which means the derived identifier is only as unguessable as its inputs. Give an outsider the context identifier, which on a shared log they have by definition, and the derived value, which is now sitting in a row, and they can test a candidate master identifier by recomputing the hash and comparing. That is a confirmation oracle: it does not hand out the answer, it grades guesses. And identifiers are a bad thing to hope are unguessable, since a plausible candidate list is often short and often public.
So the class refuses. The test is deliberately crude: the being's own local identity log counts as safe, and everything else โ a shared group's log, a channel's, anything with more than one writer โ counts as shared. Asked to stamp a pseudonymous value into the origin stamp on a shared log, the engine throws an error naming the reason rather than writing the value. The hash is no harder to grade on a local log โ none of the arithmetic changes. What changes is who can read the value: a shared log puts it in front of exactly the people it would identify you to. Local does not mean unreadable โ the readers are whoever can read your own database: your own devices, and whatever runs with access to it.
This is the shape of a claim worth making. Not this identifier cannot be linked โ it can, by anyone willing to guess and check. The claim is about where the value is allowed to exist, and about the cost of the attack in the place it is allowed to exist. A hash is not a secret when the thing hashed is guessable, and pretending otherwise is how a system ends up with a privacy feature that grades an attacker's homework. Making the failure loud is the other half: throwing an error means a future feature that reaches for the class in the wrong place finds out immediately, rather than shipping a row that quietly answers questions.
The same question at the side doors
The engine's stamp is a chokepoint โ one place where the decision gets made for the rows that go through it, instead of one per caller. A chokepoint only decides what passes through it, and in a real system of any age not every write does. Three of the places that write that same origin stamp outside the filing engine now consult the same decision: a discovery query that inserts rows directly, the receiver that lands rows arriving from another device, and the exporter that builds a data-export bundle.
The rule those three share is deliberately weaker than the engine's, and for a good reason: none of them has the log context needed to derive a per-context identifier, so their only honest options are to write the signer or to write nothing. They ask one question โ would the chokepoint refuse to expose the signer for this row kind? โ and if the answer is yes, they write nothing.
The receiver is the one to look at twice, because its input is not ours. A row arriving from another device carries an origin stamp that peer chose. If the row is of a privacy-class kind, that value is dropped and the drop is logged, rather than being inserted because a peer said so. Its own comment is candid that no privacy-class kind is currently in the set of rows it accepts from a peer at all โ so today that guard is a precaution for the moment one is added, rather than something firing in the field.
The export bundle is worth the extra call because a bundle is assembled from what the database holds and then handed to the owner, who may send it on. So the same question is asked of the row's origin column as the bundle is built, at the cost of one function call.
What this is and is not
What landed is a vocabulary and a place to enforce it: a row kind can now say what its origin stamp is allowed to reveal, the answer is resolved where the stamp is written, the three direct write paths that were updated ask the same question, and a kind that declared nothing while carrying a known-dangerous field is treated as private rather than assumed to be public.
What did not land is the ability to reason about a row's privacy without being told. The default is a tripwire on three known shapes; the build check knows a hand-written list of names; the class is per row kind rather than per field, so a kind that wants half its columns attributed and half not is not something this expresses. Those are the boundaries of a declaration-based design, and they are the price of the thing that makes it worth having: the rule is written down where a person can read it, in the feature that owns the data, instead of living in the memory of whoever last touched the filing code.
That is the same move as making a record describe its own shape rather than teaching the database about it, applied to a harder question than shape. What a record is can be read off the record. What a record is allowed to say about you cannot โ someone has to decide, and the only useful place to put that decision is next to the data, in public, where the next person will find it. Forgetting asks the same thing of a system: a link you can drop is a link something declared, in the open, before it was ever written.
Written by AI agents from real project logs; owned and edited by Mujo.
Written by AI agents from real project logs; owned and edited by Mujo.