Why Ledvar exists
An open protocol for remembering what changed.
If you keep systems running — infrastructure, security, DevOps, SRE — you know this one. A firewall port opened “just for a test” that stays open forever, because everyone forgot. An application nobody uses anymore, still sitting there for months on a machine nobody logs into. A setting loosened at three in the morning during an incident, and never tightened again. None of it is malicious — it is the natural entropy of real systems. It is drift: the environment changes quietly, and the change gets lost in time.
And we all try to hold it back the same way: scripts, checklists, checking by hand. It helps — the drift gets smaller — but it never stops. Because the hard part was never collecting the state. It was remembering it, and comparing it honestly against yesterday’s.
Where it came from
I have administered infrastructure for more than two decades, and this problem followed me the whole way — at my own company and everywhere I worked. I have been a Rust enthusiast for a while, but — like every professional in this field, who also has a life outside work — I never had the time to try to solve this by building a tool made for the purpose. And the knot was never writing code: it was finding a way to normalize how any piece of infrastructure and security information is represented — a firewall rule, a cloud permission, a file, a user, a table — so that things this different could be compared the same way, every time.
That is what unlocked it. I started using AI as a tool: well directed, it speeds the work up enormously and let me test hypotheses at a scale I would never reach alone — shapes, conditions, edge case after edge case, hunting for where my abstraction broke. Hammering on it is how the thing got small and sharp enough to become a protocol: Ledvar.
And it isn’t only about infrastructure
Then came the part I did not expect: what came out is not about infrastructure at all. The protocol does not know — and does not need to know — whether a node describes a firewall rule, a DNS record, a table’s schema, an inventory item, a SaaS subscription, or a blockchain transaction. It only knows how to compare state. If you can write the thing as a tree of nodes, Ledvar remembers it and tells you what moved.
And that is not a promise: the conformance vectors deliberately span a database, a virtual machine, a filesystem, a Bitcoin transaction, a retail product, a DNS record and a SaaS subscription — most of which have nothing to do with infrastructure or security.
What it is — and is not
Ledvar defines nouns, not verbs: what a piece of state is, and how it is hashed. It does not say what to do with it — checking, comparing, storing, alerting and displaying are the business of independent implementations built on top. It is deliberately the smallest thing that lets independent implementations agree, and nothing more.
It is also encoding-agnostic: carry a snapshot as JSON, YAML, XML or Protocol Buffers — the only fixed encoding is the handful of bytes that get hashed.
Two choices
A word on why this is open. Most of my life has been supported by this ecosystem — open tools, open knowledge, people freely sharing what they worked out. It paid my bills and my family’s. Releasing Ledvar this way is the thank-you I am able to give back.
And on why it is a protocol, not a product. Decentralization was something I only half-grasped, by reasoning about it, until Bitcoin showed me how powerful it can be: a thing nobody owns, that anyone can run, verify, and reimplement. I wanted to take that idea at its word. So Ledvar is a contract, not a product — run my implementation, or write your own and ignore mine entirely. If it is useful and it makes sense, take the idea and make it better. Thank you, Satoshi Nakamoto.
The invitation
And you do not have to take my word for any of it. What the protocol produces is verifiable: same input, same hash, byte for byte — the vectors are the proof, run them yourself and compare. How it was made does not change whether it is correct; you check.
So here is the invitation: test it. Reimplement it in your language. Try to break it. Look for the flaw — it probably exists, and finding it is the most useful thing you can do. You help this protocol grow not only by building on it, but by using it and attacking it.
I hope it makes as much sense to you as it did to me.
Where to go from here
- Getting started — install the CLI and run your first
hashanddiff. - The specification — the normative protocol.
- Writing a collector — turn real state into a Ledvar snapshot.
- The manifesto — the principles the project commits to.
On GitHub: the protocol — the specification, the
conformance vectors and the licence — and ledvar-rs, the
Rust reference implementation.
This site is a work in progress. Sections are published as each part of the ecosystem takes shape.
Getting started
Ledvar represents the state of a system as a canonical, content-addressed tree, so two snapshots can
be compared and the drift between them named exactly. This page walks every command of the ledvar
CLI, start to finish — follow along and you will know how to use it.
The unit is a snapshot: a list of nodes, where a node is an identity (path) plus
content (a map of string sets). Everything below is a real example you can run.
Install
cargo install ledvar
Prefer a prebuilt binary? Grab one from the releases page (Linux, Windows, macOS) — and verify its signature before you trust it.
A real snapshot
Download one — a MySQL database captured as a snapshot. No need to type anything:
curl -O https://raw.githubusercontent.com/ledvar/ledvar/main/examples/snapshot-a.json
It looks like this — a header, then a tree of nodes:
{
"protocol_version": "0.1.0",
"origin_id": "db.example.internal",
"provider_name": "mysql",
"timestamp": 1718800000,
"tree": [
{ "path": ["mydb", "user:app"], "content": { "grants": ["SELECT", "INSERT"], "risk": ["Medium"] } },
{ "path": ["mydb", "user:readonly"], "content": { "grants": ["SELECT"], "risk": ["Low"] } },
{ "path": ["mydb", "config:max_connections"], "content": { "value": ["200"] } }
]
}
A node’s path is an array of segments (its address in the tree), and content maps each
attribute to a set of strings. That is the whole data model.
validate — is it well-formed?
Before anything else, check the structure:
$ ledvar validate snapshot-a.json
ok: well-formed (3 nodes)
validate enforces the rules of the spec — non-empty paths, string-only values, a proper version,
no duplicate keys. It is the gate: ill-formed input is refused, never hashed (you will see that
below).
hash — the fingerprints
Every node gets two hashes:
$ ledvar hash snapshot-a.json
[
{
"path": ["mydb", "user:app"],
"identity_key": "…",
"content_hash": "…"
},
…
]
identity_key— SHA-256 of the canonicalpath. It answers “which thing is this?” Two nodes with the same identity_key are the same node.content_hash— SHA-256 of the canonicalcontent. It answers “what does it hold right now?” Same identity, different content_hash → that node changed.
That pair is the entire basis of the diff: comparing two snapshots is just matching identity_keys and checking whether the content_hash moved.
canon — the exact bytes that get hashed
This is the command that explains why Ledvar is reproducible. Take a single node — a Bitcoin transaction — and show its canonical form:
curl -O https://raw.githubusercontent.com/ledvar/ledvar/main/examples/node-btc.json
$ ledvar canon node-btc.json
path ["tx","3a1f9c0d7e2b48a6f5c1029384756abdfe0011223344556677889900aabbccdd"]
content {"amount_btc":["0.5"],"block_height":["840000"],"fee_btc":["0.00012"],"inputs":["bc1qsender0exampleaddr"],"outputs":["bc1qchange0exampleaddr","bc1qrecipient0exampleaddr"]}
identity 49a22f9cc0d6781b8847bfb9700109cd9ef279b77ae9621e5774e45508300924
content# 153bce97fc1489cb45601409acf891044615471eb9b33aa2397265594bc3280b
Look closely: in the source file the outputs were listed as recipient, change, but here they come
out change, recipient — sorted. Keys are sorted too. That is the point: the order you write
things in does not affect the hash. Two systems that hold the same state, formatted differently,
produce byte-identical canonical bytes and therefore the identical hash. Without that, a drift check
could never agree with itself.
diff — what changed
Now the reason it all exists. Download a second snapshot of that same database, taken later:
curl -O https://raw.githubusercontent.com/ledvar/ledvar/main/examples/snapshot-b.json
$ ledvar diff snapshot-a.json snapshot-b.json
~ mydb / user:app
+ grants DROP
~ risk : Medium → High
+ mydb / user:backup
+ grants = LOCK TABLES, SELECT
+ risk = Low
- mydb / user:readonly
- grants = SELECT
- risk = Low
1 added, 1 removed, 1 modified, 1 unchanged
Read it like a git diff — + added, - removed, ~ modified. In plain terms: user:app gained a
DROP grant and its risk jumped Medium → High; a new user:backup appeared; user:readonly
is gone. That is drift you would want to catch — and it fell out of the hashes alone, no rules about
what a “user” or a “grant” is.
Want machine-readable output? Add -o json and each node comes back tagged Added / Removed /
Modified / Unchanged.
When the input is bad — it refuses
A drift tool that quietly swallows garbage cannot be trusted, so Ledvar doesn’t:
$ curl -sO https://raw.githubusercontent.com/ledvar/ledvar/main/examples/reject-bad-version.json
$ ledvar validate reject-bad-version.json
error: not well-formed: protocol_version is not MAJOR.MINOR.PATCH: "1.2.0-rc1"
$ echo $?
1
Ill-formed input gets a clear reason and a non-zero exit — never a hash. The whole
examples/ folder ships reject-* files, one
per rule, each demonstrating exactly what is turned away and why.
schema — the reference shape
Need the schema to validate against, or a Protobuf definition to build a collector?
ledvar schema --out json # a JSON Schema for a snapshot
ledvar schema --out proto # the Protocol Buffers definition
Both are non-normative conveniences — the normative source is always
SPEC.md.
It isn’t only infrastructure
Nothing above knew what a database is. Ledvar is domain-blind: anything that becomes a tree of
path + content nodes can be hashed and diffed the same way. You already ran it on a Bitcoin
transaction — and a wallet is just the natural extension of that.
A Bitcoin wallet as Ledvar. Model each transaction as a node: the path is its txid, and the
content carries the amount, fee, block height, inputs and outputs — exactly the node-btc.json you
hashed above. Snapshot the wallet’s transactions today, snapshot them tomorrow, and ledvar diff
tells you precisely what moved: a new transaction is an added node, and a pending one that
confirms shows up as a modified node (its block_height goes from unset to a real height). The
hashes are the audit trail — the wallet’s history, content-addressed, where any tampering changes a
digest and surfaces as drift.
The same shape fits a DNS zone, a retail product, a VM, a filesystem entry — the conformance vectors span seven unrelated domains on purpose. The tool never changes; only what you feed it does.
Where to go next
- Write a collector — produce snapshots from your own real state.
- Specification — the normative rules behind everything here.
- Verifying releases — check the signature on a binary you downloaded.
Ledvar Protocol Specification
- Version: 0.1.0 (DRAFT)
- Protocol MAJOR: 0
- Status: Draft — definition phase. The data model and canonical form are not yet frozen.
1. Introduction
Ledvar is an open protocol for representing the state of a system as a canonical, content-addressed tree, so that two such trees can later be compared — exactly and reproducibly — to reveal what changed.
The protocol defines nouns, not verbs: what a piece of state is, and how it is hashed. It does not define what to do with it. Checking, comparing, storing, alerting and displaying are the business of implementations built on the protocol. Ledvar is deliberately the smallest thing that lets independent implementations agree — and nothing more.
It is encoding-agnostic — carry the data as JSON, YAML, XML, Protocol Buffers, or a binary packing (§8) — and domain-blind: it never knows whether a node describes a firewall rule, a virtual machine’s specs, a cloud permission, or a financial transaction. Any program, in any language, that produces the same hashes from the same input speaks Ledvar.
The name: a ledger is a book of record kept by adding, never erasing — every entry preserved so the past stays legible. A system’s state, kept the same way, is what Ledvar reads. (The name is a backronym — Ledger · Every · Diff · Versioned · Append-only · Recorded.)
Comparison (the “diff”) is not part of this specification. Turning hashes into “what changed” is a forced consequence of the data model, not something the protocol prescribes. Its representation is an optional companion standard,
DIFF.md— see §7.
2. Conventions
The key words MUST, MUST NOT, SHOULD, MAY are interpreted as in RFC 2119. An implementation is any program that consumes or produces the data model and computes the hashes defined here.
3. Scope
This specification defines (normative): the data model; node identity and content hashing (the canonical form); well-formedness; protocol versioning; conformance.
Out of scope:
- comparison / diff — a forced consequence of the data model; its representation is the optional
companion
DIFF.md; - the wire transport;
- the serialization format used for transport or storage (§8);
- storage durability and history retention;
- the meaning of any path or value;
- alerting, suppression, access control, authentication.
4. Data model
4.1 Snapshot
A Snapshot is one observation of some state at a point in time.
| Field | Type | Req. | Meaning |
|---|---|---|---|
protocol_version | string | yes | MAJOR.MINOR.PATCH (see §9) |
origin_id | string | yes | what was observed (a host, an account, a cluster…) |
provider_name | string | yes | the source that produced it |
timestamp | integer (signed, 64-bit) | yes | observation time, in seconds since the Unix epoch (UTC). MUST be held as at least a signed 64-bit integer — that has no year-2038 limit (the 2038 cutoff is a property of signed 32-bit time only). |
fingerprint | string | no | implementation-defined identity/integrity token for the source |
parent_origin_id | string | no | lineage/grouping pointer to another origin |
labels | map<string,string> | no | free-form annotation |
tree | list of Node | yes | the observed nodes |
Snapshot-level fields are metadata. They do NOT participate in any hash.
4.2 Node
A Node is identity + content.
| Field | Type | Req. | Meaning |
|---|---|---|---|
path | list of string | yes | the node’s identity: its location in the tree |
content | map<string, set<string>> | no | the node’s attributes |
labels | map<string,string> | no | annotation for grouping/clustering |
refs | list of Ref | no | directed annotation edges to other nodes |
pathis ordered and significant, and MUST be unique within a Snapshot. A different path is a different node. The hierarchy that paths look like (["etc","ssh"]“above”["etc","ssh","sshd_config"]) is a naming convention only — it carries no semantics. Nodes are independent: there is no subtree hash, a parent’s hash does not depend on its children, and removing["etc","ssh"]does not affect["etc","ssh","sshd_config"]. (This is deliberately not a Merkle tree.)contentmaps each attribute name to a set of string values. A scalar is a one-element set; a multi-valued attribute (a list of grants, a list of ports) is a larger set. Defaults to empty.labelsandrefsare pure annotation: never hashed.
Content or label? — the one question. Both can hold arbitrary strings, so the only thing that decides where a value goes is hashing: should a change to it count as drift?
- Yes — it is part of the observed state →
content. Changing it changes thecontent_hash, so it surfaces as a modification. - No — it is a human note about the node →
labels. It is never hashed, so editing it is never drift; it is there to show up in a document or UI.
Example — a firewall node with an open port someone wants to explain:
{
"path": ["firewall", "0.0.0.0:3306"],
"content": { "state": ["open"], "proto": ["tcp"] },
"labels": { "description": "Open for the API-X integration DB tests" }
}
The port being open is state — it lives in content, and a change is drift. The human
description is annotation — it rides in labels, appears in a doc or front-end, and re-wording it
never raises an alert.
And the mirror case — a VM whose cloud tag you do want to watch, next to a private note you do not:
{
"path": ["vms", "i-123"],
"content": { "cpu": ["4"], "mem_gb": ["16"], "cloud_tag": ["prod-api"] },
"labels": { "description": "provisioned for the Q3 migration" }
}
Same kind of text in both places, decided only by consequence: rename the cloud_tag and it is
drift — it is part of the VM’s real state; reword the description label and nothing fires. The
firewall and the VM together are the whole rule in one picture: content is what is true, labels
is what is noted.
4.3 Ref
| Field | Type | Meaning |
|---|---|---|
relation | string | arbitrary edge label (e.g. configures, depends_on) |
target | string | selects another node by a path-based selector |
targetselector grammar is non-normative in MAJOR 0.refsare pure annotation and are never hashed (§4.2), so the selector syntax does not affect conformance — two implementations can hash identically while resolvingtargetdifferently. The exact grammar (how a path-based selector addresses another node) is therefore left unspecified and reserved for a future companion specification; do not rely on a particular syntax across implementations until it is pinned. Resolvingtarget(e.g. for the “orphan/dependency” convenience) is a resolver concern, not a core-protocol one. Concretely — and non-normatively — two shapes are already in use for different contexts: the examples here select within one document by the target node’s path joined with/(e.g.mydb/config:max_connectionspoints at the node["mydb","config:max_connections"]), while the Ledvar reference gateway, which partitions data by provider, uses a cross-provider<provider>:<path>form (e.g.files:/etc/secret.cnf, expanded from an ideal’sdepends_on). That two forms already coexist is exactly why the grammar is reserved rather than fixed; a future companion may unify them.
4.4 Everything is a string
Every value in content is a string. The protocol never carries native numbers, booleans, or
null inside content, and never interprets a value’s type.
This is deliberate. It removes all cross-language disagreement over number/float/boolean
canonicalization — the most common way independent hash implementations diverge (is 4 equal to
4.0? does 0600 keep its leading zero? how is a float rendered?).
An implementation MAY accept or expose richer types at its edges — parsing input, rendering
output, answering queries — but it MUST reduce every value to its string form before hashing.
Reading "200" as an integer, or "Medium" as a severity, is meaning, and meaning lives in
higher layers, not in the protocol.
5. Identity and content hashing
The protocol computes exactly two values, both derived properties of a node:
identity_key(node) =HASH(canonical(path))— which node this is. Two nodes are the same node iff they share anidentity_key.content_hash(node) =HASH(canonical(content))— what the node currently is. Two nodes are equal iff they share acontent_hash.
labels and refs MUST NOT affect either hash. Re-tagging or re-linking a node is
organization, not change.
These two hashes are the whole of what Ledvar standardizes about a node. Everything anyone does later — comparing, storing, displaying — is built on them.
Choosing a good
pathis the most consequential modeling decision a collector makes. For practical guidance — identity stability, representing order, value normalization — see the non-normativeGUIDE.md.
6. Canonical form and hashing
Transport and storage MAY use any format (§8). Hashing MUST NOT. So that hashes are byte-identical across all implementations, the exact bytes that are hashed are defined here.
This is an internal hashing step, not a transport rule. The canonical form is a transient representation: an implementation reads a node from whatever format it arrived in (XML, Protocol Buffers, YAML…) into its in-memory model, builds the canonical byte string only to feed SHA-256, and discards it. Nothing is ever stored or transmitted as canonical JSON because of this rule — an XML-based implementation does not embed JSON in its XML. JSON is used here only because it is a simple, debuggable, universally implementable recipe for turning a value into deterministic bytes; another canonical encoding (e.g. canonical CBOR) would serve the same role. The recipe is fixed only so that every implementation agrees on the bytes.
6.1 Canonical encoding
The canonical encoding of a value is a JSON serialization built by the rules below. It is JSON (RFC 8259) and, for whitespace and string escaping, follows the JSON Canonicalization Scheme (JCS, RFC 8785) — with one deliberate departure from JCS: ordering is by Unicode code point, not by UTF-16 code unit (see the note below). The rules:
- A set of strings is encoded as a JSON array whose elements are sorted ascending by Unicode code point (equivalently: by their UTF-8 byte sequence) and de-duplicated. The same code-point ordering applies to object keys.
- A path is encoded as a JSON array of its segments in order — paths are NOT sorted, because order is identity.
- content is encoded as a JSON object keyed by attribute name, keys sorted ascending by code point, with no insignificant whitespace. Because every value is a string, number canonicalization never applies.
- Strings are escaped minimally (RFC 8259 §7, as JCS specifies): only
",\, and the control characters U+0000–U+001F are escaped (\b \t \n \f \rwhere defined, otherwise\u00xxwith lowercase hex digits —\u001f, never\u001F); every other character — including all multi-byte text — is emitted verbatim. Ordering (rule 1) is over the raw string values, before this escaping is applied.
All output bytes are UTF-8.
No Unicode normalization — deliberate. The protocol compares and hashes the exact Unicode scalar values it is given; it performs no normalization (NFC, NFD, NFKC, NFKD). Two strings a human reads as identical but that are encoded as different code-point sequences — e.g. “café” as
caf+ U+00E9 (NFC) versuscaf+e+ U+0301 (NFD) — are different values and hash differently. This keeps the canonical form a pure function of its input, free of any Unicode-version dependency. Making equivalent forms compare equal (e.g. normalizing filenames to NFC) is a producer (collector) concern, not the protocol’s — seeGUIDE.md§5.
Why code point (UTF-8 byte order), not UTF-16? Two different things are at play. Ordering — the sequence keys and set elements appear in — is compared by Unicode code point, which is identical to comparing the strings’ UTF-8 bytes. This is the natural ordering of many languages’ sorted string collections (Rust
BTreeMap/BTreeSet, Go, Python 3’sstr…), so those get it right for free — but Java and C# do NOT: their native string comparison (String.compareTo,String.CompareOrdinal, aTreeMap’s natural order) sorts by UTF-16 code unit, the very same trap as JavaScript. In any UTF-16-native language (JavaScript, Java, C#…) you MUST sort by the UTF-8 byte sequences — do not use the built-in string comparator. JCS itself inherited JavaScript’s UTF-16 collation; Ledvar’s canonical form deliberately departs from JCS here, because UTF-16 ordering would force every UTF-16-native implementation into an unnatural extra step that is easy to get wrong. The output — the bytes fed to SHA-256 — is always UTF-8; first you sort, then you encode. The two orderings differ only for characters outside the Basic Multilingual Plane: an astral character (e.g. an emoji) becomes a surrogate pair in UTF-16 whose first unit (U+D800– U+DBFF) sorts it before high-BMP characters likeff(U+FB00), whereas by code point it sorts after them. For ASCII and all BMP text the two are identical; thenode-astral*conformance vectors pin exactly this difference.
6.2 Hash
HASH(x) = the lowercase hexadecimal SHA-256 of the UTF-8 bytes of canonical(x).
6.3 Worked example
path ["catalog","sku:AX-42"]
canonical ["catalog","sku:AX-42"]
identity_key 40b6af8764108d36606126606c42d3e396a9e0778d7ad6a38e6bdc5804f6ad0c
content { tags: {sale, featured, sale}, price_brl: {149.90} }
canonical {"price_brl":["149.90"],"tags":["featured","sale"]}
content_hash 63c1529881beb90df3a9865bea9cafe9bf1b4701932e0aa22ce980b7a387c5a2
(A retail product — the canonical form sorts the tags set, drops the duplicate sale, and orders
the keys price_brl before tags. The mechanism is the same for any domain; see the cross-domain
vectors in examples/.)
Full vectors: examples/CONFORMANCE.md.
6.4 Two structural properties
Two safety properties fall out of the canonical form for free; they are noted here so implementations do not accidentally break them:
- No separator injection. A
pathis canonicalized as a JSON array with each segment quoted and escaped —["a","b"]— never as its segments joined by a separator. So a segment that itself contains the separator, a quote, or a backslash cannot forge a different identity:["a/b"],["a","b"]and["a\",\"b"]all canonicalize distinctly. (A naïvejoin("/")identity would not have this property.) - Domain separation between the two hashes.
canonical(path)is always a JSON array (starts with[) andcanonical(content)is always a JSON object (starts with{). The two pre-image spaces are therefore disjoint: no input toidentity_keycan collide with an input tocontent_hash, even though both use the same SHA-256.
7. Why comparison is out of scope
Given two snapshots, comparing them is pure set logic over the hashes of §5:
- a node in both with the same
content_hashis unchanged; - in both with a different
content_hash, modified; - only in the newer, added;
- only in the older, removed;
- with no prior snapshot, a baseline (cold start).
There is no sixth case. Because the outcome is fully determined by the data model, the protocol does not prescribe how to compute it — any correct implementation reaches the same answer, the way any two people dividing the same numbers get the same quotient.
What does benefit from a shared standard is the representation of a computed comparison, so
that one component can produce it and another can read it with the same vocabulary. That
representation is the optional companion standard, DIFF.md — the way JSON Patch
(RFC 6902) is a separate standard from JSON itself. An implementation MAY adopt it, define its own,
or never store comparisons at all and recompute them on demand. The core protocol requires none of
this.
8. Serialization (non-normative)
Any format that can represent the data model may carry a Snapshot over a transport or into storage:
JSON, YAML, XML, Protocol Buffers, a binary packing, etc. The choice is the implementation’s, and
is a readability-vs-performance trade-off the protocol takes no position on. See examples/
for the same node in several formats.
The only place a single encoding is mandated is §6, and only for the bytes that are hashed.
9. Well-formedness
A Snapshot is well-formed iff:
protocol_versionparses asMAJOR.MINOR.PATCH— each of MAJOR, MINOR, PATCH is0or a non-zero digit followed by digits (no leading zeros), with no pre-release or build suffix (1.2.0, not1.2.0-rc1nor01.2.0) — and its MAJOR is one the implementation supports (§10);- every required field is present; an empty
tree([]) is well-formed — it is an empty scope, not an error (guarding against a producer that emits an empty tree on failure is a producer concern, seeGUIDE.md§6); - every value in
content, every attribute name, and everypathsegment is a string in the serialization — a non-string (a JSON number, boolean, null, object, or array) is ill-formed. Reducing a richer input type to its string form (§4.4) is the producer’s job, done before serializing; a consumer that receives a non-string value rejects the document, it does not coerce it (there is no agreed string form of200.0); - every node
pathis non-empty and unique within the Snapshot, and everypathsegment is itself non-empty ([""], or any path containing an empty segment, is ill-formed); - every attribute name is non-empty (
{"":["x"]}is ill-formed), and each maps to a non-empty set — an attribute present with an empty set of values is ill-formed (encode “no value” by omitting the attribute, not by an empty set, and not by an empty name); - no object the protocol interprets contains a duplicate key — this covers attribute names in
content(the canonical model is a map from name to a set; two entries for the same name have no defined meaning and would not round-trip),labels,refs, and the snapshot/node structure itself. An unrecognized field is ignored wholesale — its interior is not inspected — so a duplicate key inside an ignored field is out of scope (to “ignore” a field is to not process it, not to validate it and then discard; requiring every implementation to strict-parse the interior of fields it does not read would be both surprising and unenforceable across parsers). Note: default JSON parsers silently collapse duplicate keys (last-wins), so enforcing this on interpreted objects needs a strict parser (see the note below); - every string that is hashed — every
pathsegment, every attribute name, and every value incontent— is a valid sequence of Unicode scalar values (i.e. valid UTF-8 with no unpaired surrogate). A value that cannot be expressed as such (an unpaired UTF-16 surrogate; a raw byte sequence that is not valid UTF-8, such as some Linux filenames) is ill-formed and MUST be encoded by the producer before it becomes a value (see the note below); - every string elsewhere in the document —
labelsnames and values,refsfields, and the metadata strings (origin_id,provider_name,fingerprint,parent_origin_id) — is also a valid sequence of Unicode scalar values. The previous rule covers strings that are hashed; this extends it to the rest, so a well-formed Snapshot is always representable. A lone surrogate in a label is never hashed, yet it makes the whole document unparseable by a strict UTF-8 reader (e.g. Rust’sserde_json), so a Snapshot carrying one is ill-formed; content, when present, is an object (a map). Omitting it denotes a node with no attributes (it canonicalizes to{}); an explicitcontent: null, or acontentthat is any non-object, is ill-formed — “no attributes” is expressed by omission, never bynull(as with the empty set);- metadata is typed:
labels, if present, maps string names to string values ({"env":42}is ill-formed, exactly as a non-stringcontentvalue is);timestampis an integer count of seconds within signed 64-bit range — a value with a non-zero fractional part (…000.5) or out of that range is ill-formed, as is a quoted string (§4.1). Because JSON number tokens do not distinguishNfromN.0uniformly across parsers (a strictint64reader rejects1718800000.0at parse; a JavaScript reader accepts it as the integer1718800000), a producer MUST serializetimestampas a bare integer literal — no decimal point, no exponent. TheN.0and integral-exponent forms (1718800000e0) are not emitted by a conforming producer, so whether a consumer accepts or rejects them is unspecified and either conforms; only a non-zero fractional part, or a value out of int64 range, is unambiguously ill-formed. Aref, if present, carries a stringrelationand a stringtarget(§4.3); - an unrecognized field MUST be ignored, not rejected — this is what lets a later MINOR add a field without breaking an older reader. This leniency is only for unknown fields; a known field that is malformed is still ill-formed by the rules above.
Well-formedness is a property, not an operation — but it is not optional. An implementation
MUST NOT hash an ill-formed Snapshot: doing so yields identity_key/content_hash values that no
other conforming implementation reproduces, so refusing ill-formed input is part of conformance (§11),
not an extra. When and how it detects ill-formedness — refusing at parse time, or in a later
validation pass — is its own concern; both conform.
An implementation MAY impose resource limits — a maximum path-segment length, node count, value size, or total document size — to bound memory and time. On exceeding a limit it MUST reject the Snapshot rather than truncate, sample, or partially hash it: a truncated Snapshot hashes to a different value than the whole, the exact divergence this specification exists to prevent.
Upper-layer note (for producers/collectors). The last three rules pin decisions that would otherwise be made silently — and differently — by each implementation, the kind a prose-only reader must not have to guess:
- An empty attribute set must not exist. A producer whose native map preserves an empty-set key (e.g. a Rust
BTreeMap<String, BTreeSet<String>>) must drop it before serializing; one whose multimap silently discards it already complies. A consumer that receives a serialized empty set rejects the document as ill-formed — it does not silently repair it to{}. Producer or consumer, no valid path leads to hashing an empty set (this matches §9 and the rejection listed inexamples/CONFORMANCE.md§2b).- Duplicate attribute names cannot survive: reject them, do not merge. In JSON this needs a strict parser —
JSON.parse/json.loadssilently collapse duplicate keys (last-wins) before you can see them, so an implementation using the default parser would hash a document these rules say to reject. Use a parser that surfaces duplicates at the deserialization boundary (Python’sobject_pairs_hook, a streaming/event parser, a strict decoder). The same caution applies to any format whose default parser silently coalesces duplicate keys.- A value that is not valid Unicode (a non-UTF-8 filename byte sequence, an unpaired surrogate) must be turned into a valid string before it enters
content— hex or base64 is the usual choice (seeGUIDE.md§8). Whether to encode it, or to skip the node and log the error, is a collector (upper-layer) decision, not a protocol one — the protocol only requires that whatever reaches a hash is valid Unicode.
10. Protocol versioning
protocol_version is MAJOR.MINOR.PATCH. Only MAJOR is contract-significant. The current MAJOR
is 0. An implementation MUST reject a Snapshot whose MAJOR differs from the one it supports.
A MAJOR bump may change the data model or the canonical hashing form — it begins a new, incompatible hash universe. MINOR and PATCH changes MUST NOT change any hash.
During MAJOR 0 (draft), this stability guarantee does not yet apply. The “MINOR and PATCH MUST NOT change any hash” rule holds from MAJOR ≥ 1 onward. While the protocol is at MAJOR 0 the data model and canonical form are still being finalized (§12), so a MINOR bump within 0.x MAY change the canonical form and therefore hashes — but a PATCH within 0.x MUST NOT: during 0.x, any hash-affecting change MUST be released as a MINOR bump, never a PATCH. That discipline (a 0.x PATCH is text/examples/typo only, hashes intact) is exactly what makes the MINOR-pinning rule below sufficient — without it, a 0.1.0 reader could accept a 0.1.1 that silently moved the canonical form. Hash stability begins the moment a non-zero MAJOR is published, and that MAJOR’s canonical form is then permanent.
Consequently, while MAJOR is 0 an implementation MUST also reject a Snapshot whose MINOR differs from the one it implements. During 0.x the exact MINOR is contract-significant, precisely because a MINOR bump may have moved the canonical form — without this, a 0.1 reader would silently accept a 0.2 Snapshot whose hashes belong to a different universe. From MAJOR ≥ 1 onward this extra check falls away and only MAJOR gates compatibility, per the rule above.
11. Conformance
An implementation conforms to this core specification iff, for every vector in
examples/CONFORMANCE.md, it computes the listed identity_key and
content_hash exactly, and it refuses every ill-formed Snapshot — it MUST NOT hash one (§9). The
reject-* vectors in examples/CONFORMANCE.md §2b are the machine-checkable
form of that second half. Conformance is therefore exactly two things — reproduce every listed hash,
and never produce a hash for ill-formed input — and nothing beyond those two. (The optional companion
DIFF.md defines its own, separate conformance for those who adopt it.)
12. Status
Draft, MAJOR 0. The data model and canonical form are still open to change. Once a non-zero MAJOR is published, the canonical hashing form is permanent for that MAJOR.
Ledvar Diff — Companion Standard
- Version: 0.1.0 (DRAFT)
- Companion to: the Ledvar Protocol, MAJOR 0 (
SPEC.md) - Status: Draft. Optional.
1. What this is — and what it is not
This is an optional, open standard for representing the comparison of two Ledvar snapshots. It is a companion to the core protocol, not a part of it — the way JSON Patch (RFC 6902) is a separate standard from JSON itself.
The core protocol (SPEC.md) standardizes how state is represented and hashed, and
nothing more. Comparing two snapshots is a forced consequence of that — any correct implementation
reaches the same answer. So this document does not add new truth; it only fixes a shared
vocabulary for a computed comparison, so that one component can produce a result and another can
read it without guessing.
You MAY:
- adopt this standard, so your comparison results interoperate with anyone else who adopts it;
- define your own representation;
- store nothing and recompute comparisons on demand (the way
git diffderives a diff from stored objects).
The core protocol requires none of this. Conformance to SPEC.md does not depend on
this document.
2. The comparison
Given a previous snapshot and a current snapshot, match their nodes by identity_key
(SPEC.md §5). Each node receives exactly one StateStatus:
| StateStatus | Condition |
|---|---|
Baseline | there is no previous snapshot (cold start) — every node is Baseline |
Added | in current, not in previous |
Removed | in previous, not in current |
Unchanged | in both, equal content_hash |
Modified | in both, different content_hash |
These outcomes are exhaustive and mutually exclusive: every node is exactly one, and there is no
sixth case. This is not a design choice — it is what comparing two sets yields. Added, Modified
and Removed are collectively drift.
Baseline means there is no previous snapshot at all — a cold start. This is intentionally
distinct from a previous snapshot whose tree is empty: comparing against an empty previous is a
normal comparison and yields every current node as Added, not Baseline. “No comparand” and “an
empty comparand” are different states.
Whether a node changed is settled here by arithmetic. What the change means — whether it matters, whether it is allowed — is not. That is meaning, and meaning lives in higher layers (see the Manifesto, “context lives elsewhere”).
3. Result representation
A DiffNode is one node of a comparison result:
| Field | Type | Meaning |
|---|---|---|
node | Node | the node as it appears in current (or, for Removed, as carried from previous) |
identity_key | string | from SPEC.md §5 |
content_hash | string | from SPEC.md §5 |
state_status | StateStatus | §2 |
A DiffResult carries the current snapshot’s metadata (protocol_version, origin_id,
provider_name, timestamp, fingerprint, parent_origin_id, labels) plus a list of DiffNodes. It
MAY also carry an optional previous reference (the previous snapshot’s timestamp and/or
fingerprint) so a reader knows what current was compared against — otherwise that comparand is
lost once the result is serialized. This reference is a convenience only: whether a result is a
Baseline is already evident without it, since every node’s state_status is then Baseline.
This vocabulary is deliberately fact-only: a DiffNode says what changed, and nothing about whether
a change is acknowledged, silenced, severe, or allowed. Those are opinions a consumer layers on top
— they live in the consumer’s own representation, in its own layer, and are never standardized here (see
§2, and the core SPEC.md §4.4). A consumer that wants to carry, say, a suppression flag or
a severity does so in its own envelope around a DiffNode; because such judgements are
policy-specific (one system’s “silenced” is another’s “acknowledged”), standardizing them would hurt
interoperability, not help it.
4. Conformance (for adopters)
An implementation of this companion standard conforms iff, for the example snapshots
examples/snapshot-a.json (previous) and
examples/snapshot-b.json (current), it assigns the state_status
below to each node. (The hashes are the core protocol’s; see
examples/CONFORMANCE.md.)
| node (path) | in A | in B | StateStatus |
|---|---|---|---|
mydb / user:app | Medium, {SELECT,INSERT} | High, {SELECT,INSERT,DROP} | Modified |
mydb / config:max_connections | {200} | {200} | Unchanged |
mydb / user:backup | — | {SELECT,LOCK TABLES} | Added |
mydb / user:readonly | {SELECT} | — | Removed |
5. Status
Draft, optional companion to MAJOR 0. Like the core, once a non-zero MAJOR of this companion is published its result vocabulary is stable for that MAJOR.
Ledvar Modeling Guide
- Companion to: the Ledvar Protocol, MAJOR 0 (
SPEC.md) - Status: Non-normative. Guidance, not rules.
Who this is for
If you are writing a collector — anything that observes some state and produces a Snapshot —
this guide is for you. The protocol (SPEC.md) guarantees one thing: the same canonical
input always produces the same hash. It does not tell you how to turn a real firewall, VM, or
transaction into nodes. That modeling is yours, and a handful of choices decide whether your drift is
clean and honest or noisy and misleading.
None of this is required for conformance — a collector that ignores every word here is still valid Ledvar. But two collectors that disagree on these choices will produce different hashes for what a human would call “the same state.” Following the same conventions is what makes results comparable across tools and over time.
1. Choosing the identity (path) is the most important decision
A node’s path is its identity: a comparison matches old against new by identity_key = hash(path).
So the path must be stable (it does not change when irrelevant things change) and unique (two
different things never share it).
A bad identity produces churn — endless false Added/Removed pairs:
- a process keyed by PID (the PID changes on every restart);
- a firewall rule keyed by its line number (inserting one rule renumbers all the rest).
A good identity is a stable natural key, or a fingerprint of the thing’s defining traits:
- a user keyed by name, not by an internal row id;
- a firewall rule keyed by a hash of its match+action, with the line number kept as a content
attribute — so a re-order is a single
Modified, not a churn ofRemoved+Added.
Rule of thumb: “if this thing is unchanged but the world around it shifts, does its path stay the same?” If not, pick a different path.
2. content is an unordered, de-duplicated set — encode order and duplicates explicitly
Each attribute maps to a set of strings: the protocol sorts it and drops duplicates before hashing. Perfect when order doesn’t matter (a user’s grants, a group’s members). Wrong when it does.
If order matters (a firewall chain evaluated top-to-bottom; Ethereum log topics, where position is the meaning), encode the position into the value:
"topics": ["0:0xddf2…", "1:0x…sender", "2:0x…recipient"]
or promote each element to its own child node whose path carries the index. Without this,
["a","b","c"] and ["c","b","a"] hash identically and a reorder becomes invisible.
If duplicates matter (a true multiset — two identical line items that really are two), the set
collapses them. Tag each with an instance id: ["item#1:widget", "item#2:widget"].
3. Structured and nested data — a subtree, or an encoded string
content values are flat sets of strings, not nested objects. Real things are often nested (a
security group holding a list of rule objects). Two ways to model it:
- Promote to child nodes when you want field-level drift. Each rule becomes its own node:
path = [..., "sg-123", "ingress", "tcp-443"],content = {proto, from_port, to_port, cidr}. A change to one field is aModifiedon that one rule. - Flatten into a string when coarse drift is enough:
"ingress": ["tcp:443:443:0.0.0.0/0", "tcp:22:22:10.0.0.0/8"]. Any change re-hashes the whole element; you lose which field changed, but it is compact.
Never put a blob in content. Store its digest: "sha256": ["b1946ac…"]. The protocol hashes
your hash.
4. Relationships: content for drift, refs for annotation
A link between nodes can live in two places — the same labels-vs-content question:
- If a change to the relationship should be drift (a volume detached from a VM, a user removed
from a role), put the target ids in
contentas a set:"attached_volumes": ["vol-1","vol-2"]. Detachingvol-2changes the content hash. - If the relationship is pure annotation (a “this configures that” edge drawn for a diagram), use
refs. Refs are never hashed, so adding or removing one is never drift.
5. Normalize your values — the protocol compares them literally
Because everything is a string, the protocol compares values byte for byte. "0.5" and "0.50"
differ. "ACCEPT" and "accept" differ. "0600" and "384" (the same mode in octal vs decimal)
differ.
So value normalization is your job. Pick one canonical representation per attribute — lowercase hex, octal modes with the leading zero, RFC 3339 timestamps in UTC, a fixed decimal precision — and emit it the same way every time. If two versions of your collector format the same value differently, they report drift where nothing changed. This is the most common source of false positives, and the protocol cannot catch it for you.
Unicode normalization is part of this — and it is your job, not the protocol’s. The protocol does
no normalization (SPEC.md §6): “café” written as caf+U+00E9 (NFC) and as
caf+e+U+0301 (NFD) are different byte sequences and hash differently, even though they look the
same. macOS hands filenames back as NFD, Linux as NFC — so a cross-platform filesystem collector that
does nothing here reports phantom drift for the same file. Normalize deliberately, per source:
for human-facing text values, normalize to NFC; but for a value whose whole point is to match the
bytes on disk — a filename — do not normalize, or your identity stops matching what is really
there. Decide once per attribute and emit it the same way every time.
Beware implicitly-typed formats. Everything is a string (SPEC.md §4.4), but some
serializations don’t preserve that on their own: in YAML (and TOML, and some JSON tooling) an
unquoted scalar is auto-typed — 149.90 parses back as the float 149.9, 0600 may become the
integer 384, yes/no become booleans. Two consumers of the same careless document then disagree
on the string, and hash differently. So when you emit or read these formats, keep every value a
quoted string ("149.90", "0600"), and reduce anything typed back to its canonical string form
before hashing. JSON with string values and Protocol Buffers string fields don’t have this trap;
YAML and TOML do.
Range-check timestamp on the raw token in floating-point languages. In JavaScript, Lua, and
other double-precision languages, the default JSON parser reads a large integer as an IEEE-754 float —
so 9223372036854775807 (int64 max, legal) and 9223372036854775808 (out of range, ill-formed per
SPEC.md §9) both round to the same float and are indistinguishable after parsing. A
validator in such a language must range-check the raw token (or parse it as a big integer), not the
parsed number. This is a timestamp-only concern — it is metadata, never hashed, and real timestamps
sit ten orders of magnitude below the boundary, so the practical risk is nil; the note just spares a
reimplementer the debugging.
6. Snapshot scope — stable state, and the whole of it
- Model state that is meant to be stable: configuration, posture, structure, declared limits. Do not model fast-moving metrics (live CPU %, current connection count) — they change every snapshot and bury real drift in noise. Model the configured memory limit, not the current usage.
- A snapshot must be complete for its declared scope. A comparison treats any node present last
time and absent now as
Removed. If a lens fails halfway and emits fewer nodes, every missing node looks deleted — a flood of false removals. Either emit the complete state of your scope, or narrow the scope (a subtree) so that “complete” is something you can guarantee.
7. Identity and origin are separate
identity_key is hash(path) — it does not include the snapshot’s origin_id. So the file
["etc","ssh","sshd_config"] has the same identity on every host. That is a feature: it lets you
compare the same file across a fleet. But it means:
- to diff a single host’s timeline, the store keys by
(origin_id, identity_key); - if you want nodes from different hosts to be distinct entities, put the host in the path:
["host:web-01","etc","ssh","sshd_config"].
Decide on purpose whether a node’s identity is global or per-origin, and encode that choice in the
path. origin_id is metadata; it never enters a hash.
8. Numbers and binary are strings — and that’s a strength
There is no number type, and you don’t want one. Strings give you arbitrary precision for free: a
Bitcoin amount in satoshis, an Ethereum uint256 balance (far beyond a 64-bit integer), a
nanosecond timestamp — all exact, no overflow, no float rounding. Binary becomes a string too: hex
or base64. The collector picks the representation (see §5); the protocol just hashes the bytes.
Non-Unicode bytes must be encoded, too. The protocol requires every hashed string to be valid
Unicode (SPEC.md §9), but the real world hands you bytes that are not: a Linux filename
can be an arbitrary byte sequence that is not valid UTF-8; a string from a UTF-16 language can carry
an unpaired surrogate. You cannot put those into content raw. Encode them — hex or base64 — the
same way you would any other binary value, and (if useful) keep a best-effort human-readable form in a
label. Whether to encode such a node or to skip it and log the error is your decision as the
collector — the protocol only insists that whatever it hashes is valid Unicode; it does not tell you
which of the two to do.
A note on domain conventions
This guide is general. The specific question — exactly how to encode an iptables rule, an EC2
instance, or an Ethereum transaction so that everyone’s collectors agree — is a per-domain
convention, and those are best written as their own companion documents over time (the way
DIFF.md is a companion to the core). Keeping them out of the core protocol is
deliberate: the protocol stays small, and each domain settles its conventions at its own pace.
Conformance
An implementation conforms to the core protocol iff it does exactly two things:
- Reproduces every listed hash — the same
identity_keyandcontent_hashfor every vector, and - Refuses every ill-formed snapshot — it never produces a hash for input the spec rejects.
Nothing beyond those two. If your implementation, in any language, passes the vectors, it is Ledvar (it speaks the protocol — see the name policy).
The vectors
The ground-truth vectors live in the protocol repository:
examples/CONFORMANCE.md. They
span many unrelated domains on purpose — a database, a VM, a filesystem, a Bitcoin transaction, a
retail product — to make the point that the protocol is domain-blind.
A standalone, dependency-free verifier (tools/conformance_check.py)
re-implements the canonical form and checks that every vector reproduces its published hash.
The trickiest vectors are the
node-astral*ones: they pin code-point ordering (not UTF-16), which is the one place UTF-16-native languages (JavaScript, Java, C#) tend to get it wrong.
Writing a collector
A collector is anything that observes some state — a server, a database, a cloud account, a config file — and produces a Ledvar snapshot. The protocol guarantees the hashing; how you turn the real world into nodes is your modeling decision, and a handful of choices decide whether your drift is clean or noisy.
This is the hands-on companion to the normative modeling guide.
The shape of a fact
Each node is identity + content + labels + refs:
path— the node’s identity. Must be stable (doesn’t change when irrelevant things change) and unique. A bad identity produces churn: don’t key a process by PID, or a firewall rule by line number.content— the observed values, as sets of strings. This is what, when it changes, is drift.labels— human notes. Never hashed, never drift.refs— annotation edges to other nodes. Never hashed.
The four rules that matter most
- Choose a stable identity. “If this thing is unchanged but the world around it shifts, does its path stay the same?” If not, pick a different path.
- Everything is a string — normalize it.
"0.5"≠"0.50";"ACCEPT"≠"accept". Pick one canonical form per attribute and emit it the same way every time. - Sets are unordered and de-duplicated. If order matters, encode the position into the value —
a firewall chain evaluated top-to-bottom becomes
["0:allow tcp:22", "1:allow tcp:80", "2:deny all"]; a bare set would hash the same in any order, hiding a reorder. - Be complete for your scope. A missing node reads as removed. Emit the whole state of your scope, or narrow the scope so “complete” is something you can guarantee.
A minimal example
Observing two files and emitting a snapshot:
{
"protocol_version": "0.1.0",
"origin_id": "web-01",
"provider_name": "files",
"timestamp": 1718800000,
"tree": [
{ "path": ["files", "/etc/ssh/sshd_config"],
"content": { "mode": ["0600"], "owner": ["root"] } },
{ "path": ["files", "/etc/hosts"],
"content": { "mode": ["0644"], "owner": ["root"] } }
]
}
This page is a starting point — a fuller, worked walkthrough (a real iptables / cloud collector) is coming. For the complete set of modeling rules, read the modeling guide.
Implementation — ledvar-rs
ledvar-rs is the Rust reference implementation: a small,
dependency-light workspace of three crates.
| Crate | Kind | What it is |
|---|---|---|
ledvar-core | lib | the data model, canonical hashing, well-formedness |
ledvar-diff | lib | the optional StateStatus comparison (the DIFF.md companion) |
ledvar | bin | the CLI: validate, hash, diff, canon, schema |
API reference
The library API is documented on docs.rs (generated automatically):
docs.rs/ledvar-core— the model + hashingdocs.rs/ledvar-diff— the comparison
CLI at a glance
ledvar validate snapshot-a.json # check it is well-formed
ledvar hash snapshot-a.json # each node's identity_key + content_hash
ledvar diff snapshot-a.json snapshot-b.json # what changed between two snapshots
ledvar canon snapshot-a.json # the exact bytes fed to SHA-256
ledvar schema --out json # the reference schema
Input and output formats are independent axes (--in / --out). JSON is always available; YAML is
opt-in behind a build feature.
New to the CLI? The Getting started page runs every one of these against real example snapshots, with the output shown.
Any language is welcome
The reference implementation is Rust, but the protocol is language-agnostic. Bring it to your language — hash a node the same way, byte for byte, and prove it against the conformance vectors.
The full install and usage docs live in the
ledvar-rsREADME; this page is the overview.
Design note: canonical hashing — hand-rolled & streaming
Status: accepted. This explains a choice that is easy to second-guess later, so it is written down and committed.
The problem
Two independent implementations of Ledvar must produce byte-identical hashes
for the same node — otherwise the whole protocol falls apart (SPEC §2, §6). To
hash a node we turn its path/content into one exact byte string (the
canonical form) and feed it to SHA-256. If two implementations build that
string even one byte differently (key order, whitespace, string escaping), the
hashes diverge.
The SPEC fixes the recipe as JCS (RFC 8785) with protocol rules: object keys sorted, value-sets sorted and de-duplicated, no insignificant whitespace, and — crucially — every value is a string, so JCS number canonicalization (the hard part of the standard) never applies to us.
The decision: hand-rolled, not a JCS crate
We build the canonical bytes ourselves (crates/ledvar-core/src/canon.rs)
instead of pulling a general JCS/serde_jcs crate. Why:
- Our subset is tiny. Path = array of strings; content = map of string → set of strings. The only non-trivial part is JSON string escaping (~20 lines). A general JCS library carries machinery for numbers/floats/nesting we never use.
- Dependency-light.
ledvar-coreis embedded by every higher layer; its only deps areserde(wire model) andsha2(hashing). Fewer moving parts to trust and audit. - Exact control. The bytes are defined by our code and pinned by the golden conformance vectors. A third-party crate could change behavior across versions and silently shift our hashes.
- Performance — see below.
The safety net is the golden vectors (conformance/): if the canonicalizer is
ever wrong, those tests fail immediately.
Performance: streaming, zero intermediate allocation
The library is on the hot path (a gateway may hash every node of every snapshot from a whole fleet), so hashing must be cheap.
- Input parsing is not part of hashing. A YAML/XML/proto reader parses directly into the in-memory model — there is no “convert to JSON” step.
- Hashing streams. SHA-256 is incremental, so we feed the canonical bytes
straight into the hasher as we walk the (already sorted)
BTreeMap/BTreeSet— punctuation, then JSON-escaped strings — with no intermediateStringallocation. The “JSON-ness” of the canonical form is just the shape of the bytes; there is no JSON parser in the hashing path. - The same escaping routine is parameterised over a
Sink, so the inspection path (canoncommand /Node::canonical_*) reuses it to materialise the string into a buffer — without slowing the hashing path. BTreeMap/BTreeSetpay the ordering/de-duplication cost once on construction; hashing then iterates already-canonical and isO(bytes).
This is also why hand-rolled wins on performance: a JCS crate typically builds
a String first (an allocation per hash); we never do.
String escaping (the one fiddly part)
Per RFC 8785 / RFC 8259, only these are escaped inside a JSON string:
" → \", \ → \\, and the control characters U+0000..=U+001F — using the
short forms \b \t \n \f \r where defined, otherwise \u00XX. Everything else,
including all multi-byte UTF-8, is emitted verbatim. Byte-wise iteration is
safe because every escaped character is single-byte ASCII and UTF-8 continuation
bytes are all ≥ 0x80.
Ordering of non-BMP characters (resolved)
We rely on Rust’s BTreeMap/BTreeSet, which order by Unicode scalar value
(code point) = UTF-8 byte order. JCS (RFC 8785) instead sorts by UTF-16 code
unit. The two are identical across the entire Basic Multilingual Plane — all
ASCII and essentially every realistic key/value — and diverge only for
supplementary characters (U+10000 and above, e.g. some emoji) used as a key or
set element.
Resolved: SPEC §6.1 was amended to specify code-point ordering as a
deliberate, documented departure from JCS — matching what this implementation
already does, so spec and impl agree. The node-astral* conformance vectors pin
the difference (the test fails if the ordering ever regresses to UTF-16). This is
no longer a release blocker.
Verifying releases
Every ledvar release binary is published with two companion files:
| File | Proves | Protects you from |
|---|---|---|
.sha256 | integrity — the bytes arrived intact | a corrupted download |
.asc | authenticity — the file is genuinely ours | someone replacing the binary |
Only the second one is a security control. A checksum on its own protects against nothing hostile: whoever can swap the binary can swap the checksum sitting next to it. A signature can’t be forged without the private key — and that key never leaves the maintainer’s machine, never touches CI.
The signing key
750F 73CA 0EC5 9FB3 D945 CA12 911C CB7A 80D3 49FB
Maykon Luiz Matos Araújo <maykon.lma@gmail.com> · Ed25519 · created 2026-07-12
Download it here: ledvar-public-key.asc
The same key is also published in the protocol repository. They must match. Publishing the fingerprint in two independent places is the whole safeguard: an attacker who controls one of them still can’t make the two agree.
Verify a download
# 1. import the key — once
gpg --import ledvar-public-key.asc
# 2. confirm it is the RIGHT key — compare with the fingerprint above
gpg --fingerprint 911CCB7A80D349FB
# 3. verify the release you downloaded
gpg --verify ledvar-v0.1.0-x86_64-unknown-linux-musl.tar.gz.asc \
ledvar-v0.1.0-x86_64-unknown-linux-musl.tar.gz
A good result looks like this:
gpg: Good signature from "Maykon Luiz Matos Araújo <maykon.lma@gmail.com>"
Do not skip step 2. A valid signature only means something once you know the key belongs to the project. Otherwise an attacker simply hands you a malicious binary together with a key that signs it flawlessly — and every check passes. The fingerprint is the anchor; everything else hangs off it.
You may also see WARNING: This key is not certified with a trusted signature. That is normal and
not a failure — it only means you haven’t personally marked the key as trusted in your own keyring.
The fingerprint check is what settles it.
What is signed
- Release artifacts — every
.tar.gz/.zipon the ledvar-rs releases page. - Release tags — the git tag itself carries a signature.
Signatures are detached: the .asc sits beside the file and the binary itself is untouched, so
you can verify a download without altering it.
Published releases are also immutable — once a release is live, its binaries can never be replaced, not even by the maintainer. Signing proves who made it; immutability proves it hasn’t changed since. A fix means a new version, never an edit to an old one.
If the key is ever compromised
A revocation certificate exists for exactly that. If it is ever used, the key is published in its
revoked form and gpg --verify will report This key has been revoked — and this page will say so.
Past releases stay verifiable and frozen; only new signatures would be in question.
The Ledvar Manifesto
A protocol for remembering what changed.
Systems do not change all at once. They drift — a setting loosened here, a rule shadowed there, a value quietly raised and never lowered. Each change is small, reasonable, and forgotten by morning. You cannot understand a system you cannot compare against yesterday.
A ledger is a book of record kept by adding, never erasing — every entry preserved, in order, so the whole past stays legible. That is what a running system’s state should be: layer upon layer, each change recorded and kept, the past never lost. Ledvar — Ledger · Every · Diff · Versioned · Append-only · Recorded — is a protocol for writing those layers down in a way that can be compared, precisely and reproducibly, to reveal what changed.
It began as a way to watch security state drift. But what it standardizes never knew what “security” was — only how to describe a piece of state and hash it. So the protocol is deliberately broader than its first use: a node can describe a firewall rule, a virtual machine, a cloud permission, a repository’s access list, even a transaction. If it can be written as a tree, something built on Ledvar can remember it. Security is the first thing we built with it, not the limit of it.
A note on voice. Ledvar is a protocol — a small contract about what state is and how it is hashed. The pieces that actually watch a system, store its history, compare snapshots, and raise alerts are independent implementations built on it, each its own project. So where this manifesto says Ledvar “watches” or “remembers,” read it as what a system built on Ledvar does: the protocol is what makes that possible — and what keeps it honest.
None of what follows is settled doctrine. It’s what the project believes today, stated plainly so it can be argued with — and the most useful thing you can do is find where it’s wrong.
What we believe
1. Nouns, not verbs — and as few nouns as possible. The protocol says what a piece of state is and how to hash it. It does not say what to do with it: comparing, storing, alerting, displaying are all left to others. It has no clock, no memory, no opinions. The smaller the contract, the more places it survives unbroken and the easier it is to verify. Cleverness is where bugs and bias hide; there is almost nothing here to hide in.
2. One canonical hash, one truth.
The same state, on any machine, in any language, on any day, produces byte-identical hashes.
Identity is “same path”; equality is “same content hash.” That determinism is the whole foundation:
it is what lets a fleet of machines, two implementations, and a year of history all be compared with
a single =. Everything else is built on top of it.
3. What counts as a change is settled by mathematics, not opinion. Match two snapshots by node identity and each node is exactly one of: unchanged, modified, added, removed — plus the cold-start baseline. There is no sixth case, and none is left unclassified. It is not a design choice — it is what comparing two sets gives you. The protocol does not even prescribe how to compare; it only makes the answer inevitable. Whether a node changed is arithmetic; what the change means is for higher layers.
4. Context lives elsewhere — and stays honest because of it.
Meaning — what is risky, what is allowed, what a value means, even whether "200" is a number —
lives in higher layers, never in the protocol. A collector states what it sees; people decide
what it means. A contract that mixes facts with opinions can be trusted with neither.
5. The past stays legible. The protocol can’t enforce this — it is stateless and holds no power over how anyone stores what they build from it. It’s a discipline: a store built for Ledvar keeps history append-only, overwriting and deleting nothing in normal operation. That immutable timeline is the point — a record where every entry in the ledger is still there to be read. History you can edit is not history.
6. A protocol, not a product.
The contract is data and rules, not a binary you must run. Implement it in Go, Python, Rust, or
anything that can hash. If your implementation produces the same hashes, it is Ledvar. No
vendor, no lock-in, no blessed runtime — and no blessed serialization: carry it as JSON, YAML,
protobuf, whatever fits. Only the bytes that get hashed are pinned. (“It is Ledvar” here means it
speaks the protocol — same bytes, same hashes. It is a statement about technical conformance, not
permission to use the name as your own product’s brand; see TRADEMARKS.md.)
7. No telemetry. Ever. The whole point is to watch your systems for you — never to watch you. The protocol defines no telemetry, carries none, and asks every implementation to add none: your state, your history, your metadata never leave your control. A monitoring tool that surveils its own user has failed its first duty.
8. Verifiable beats trusted. You should not have to take our word for anything. The hashes are reproducible; the comparison is forced by math; an honest store keeps every layer. Run it yourself, byte-compare the output, read the history. Trust that can be checked is the only kind worth having.
An invitation
This is an open protocol, and it is better with you in it.
- Write a collector. Anything with state worth watching — a server, a database, a cloud account, a repository, a config file — can feed it. If you can describe its state as a tree, something built on Ledvar can remember it.
- Implement it. Bring the protocol to your language — hash a node the same way, byte for byte. Prove it against the conformance vectors.
- Break it. Find the input that two implementations hash differently, the case the math missed, the snapshot that slips past well-formedness. Adversaries make protocols honest.
- Keep it small. The best contribution is often the rule we didn’t add. Guard the smallness like it’s the whole point — because it is.
Remember what changed. Make it provable. Tell no one but yourself.
— The Ledvar project
Ecosystem
🚧 Under construction.
The protocol is deliberately small — a contract about what state is and how it is hashed. The pieces that actually watch a system, store its history, compare snapshots, and raise alerts are independent implementations built on it, each its own project.
A preview
A reference front-end — Ledvar view — is being built. Here’s an early look; it’s a work in progress and the visual form may still change.
Drift on a machine — what changed, node by node, with a risk read.
Fleet conformance — every machine scored against a shared ideal.
Multi-origin compare — the same collector across machines, divergences first.
What’s coming
This section grows as each piece is published:
- Collectors — observe some state and produce a snapshot. (planned)
- Gateway / store — receive snapshots and keep history append-only. (planned)
- Policy & alerting — turn drift into decisions. (planned)
- Front-end — read the history and show what moved. (planned)
Want to build one? Anything with state worth watching can feed the protocol. Start from the modeling guide and prove your collector against the conformance vectors.