Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

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 canonical path. It answers “which thing is this?” Two nodes with the same identity_key are the same node.
  • content_hash — SHA-256 of the canonical content. 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

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.

FieldTypeReq.Meaning
protocol_versionstringyesMAJOR.MINOR.PATCH (see §9)
origin_idstringyeswhat was observed (a host, an account, a cluster…)
provider_namestringyesthe source that produced it
timestampinteger (signed, 64-bit)yesobservation 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).
fingerprintstringnoimplementation-defined identity/integrity token for the source
parent_origin_idstringnolineage/grouping pointer to another origin
labelsmap<string,string>nofree-form annotation
treelist of Nodeyesthe observed nodes

Snapshot-level fields are metadata. They do NOT participate in any hash.

4.2 Node

A Node is identity + content.

FieldTypeReq.Meaning
pathlist of stringyesthe node’s identity: its location in the tree
contentmap<string, set<string>>nothe node’s attributes
labelsmap<string,string>noannotation for grouping/clustering
refslist of Refnodirected annotation edges to other nodes
  • path is 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.)
  • content maps 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.
  • labels and refs are 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 statecontent. Changing it changes the content_hash, so it surfaces as a modification.
  • No — it is a human note about the nodelabels. 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

FieldTypeMeaning
relationstringarbitrary edge label (e.g. configures, depends_on)
targetstringselects another node by a path-based selector

target selector grammar is non-normative in MAJOR 0. refs are pure annotation and are never hashed (§4.2), so the selector syntax does not affect conformance — two implementations can hash identically while resolving target differently. 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. Resolving target (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_connections points 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’s depends_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 an identity_key.
  • content_hash(node) = HASH(canonical(content))what the node currently is. Two nodes are equal iff they share a content_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 path is the most consequential modeling decision a collector makes. For practical guidance — identity stability, representing order, value normalization — see the non-normative GUIDE.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:

  1. 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.
  2. A path is encoded as a JSON array of its segments in order — paths are NOT sorted, because order is identity.
  3. 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.
  4. 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 \r where defined, otherwise \u00xx with 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) versus caf + 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 — see GUIDE.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’s str…), so those get it right for free — but Java and C# do NOT: their native string comparison (String.compareTo, String.CompareOrdinal, a TreeMap’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 like (U+FB00), whereas by code point it sorts after them. For ASCII and all BMP text the two are identical; the node-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 path is 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ïve join("/") identity would not have this property.)
  • Domain separation between the two hashes. canonical(path) is always a JSON array (starts with [) and canonical(content) is always a JSON object (starts with {). The two pre-image spaces are therefore disjoint: no input to identity_key can collide with an input to content_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_hash is 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_version parses as MAJOR.MINOR.PATCH — each of MAJOR, MINOR, PATCH is 0 or a non-zero digit followed by digits (no leading zeros), with no pre-release or build suffix (1.2.0, not 1.2.0-rc1 nor 01.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, see GUIDE.md §6);
  • every value in content, every attribute name, and every path segment 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 of 200.0);
  • every node path is non-empty and unique within the Snapshot, and every path segment 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 path segment, every attribute name, and every value in content — 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 documentlabels names and values, refs fields, 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’s serde_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 explicit content: null, or a content that is any non-object, is ill-formed — “no attributes” is expressed by omission, never by null (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-string content value is); timestamp is 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 distinguish N from N.0 uniformly across parsers (a strict int64 reader rejects 1718800000.0 at parse; a JavaScript reader accepts it as the integer 1718800000), a producer MUST serialize timestamp as a bare integer literal — no decimal point, no exponent. The N.0 and 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. A ref, if present, carries a string relation and a string target (§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 in examples/CONFORMANCE.md §2b).
  • Duplicate attribute names cannot survive: reject them, do not merge. In JSON this needs a strict parserJSON.parse / json.loads silently 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’s object_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 (see GUIDE.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 diff derives 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:

StateStatusCondition
Baselinethere is no previous snapshot (cold start) — every node is Baseline
Addedin current, not in previous
Removedin previous, not in current
Unchangedin both, equal content_hash
Modifiedin 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:

FieldTypeMeaning
nodeNodethe node as it appears in current (or, for Removed, as carried from previous)
identity_keystringfrom SPEC.md §5
content_hashstringfrom SPEC.md §5
state_statusStateStatus§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 Ain BStateStatus
mydb / user:appMedium, {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 of Removed+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 a Modified on 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 content as a set: "attached_volumes": ["vol-1","vol-2"]. Detaching vol-2 changes 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:

  1. Reproduces every listed hash — the same identity_key and content_hash for every vector, and
  2. 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

  1. 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.
  2. 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.
  3. 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.
  4. 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.

CrateKindWhat it is
ledvar-corelibthe data model, canonical hashing, well-formedness
ledvar-difflibthe optional StateStatus comparison (the DIFF.md companion)
ledvarbinthe CLI: validate, hash, diff, canon, schema

API reference

The library API is documented on docs.rs (generated automatically):

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-rs README; 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:

  1. 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.
  2. Dependency-light. ledvar-core is embedded by every higher layer; its only deps are serde (wire model) and sha2 (hashing). Fewer moving parts to trust and audit.
  3. 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.
  4. 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 intermediate String allocation. 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 (canon command / Node::canonical_*) reuses it to materialise the string into a buffer — without slowing the hashing path.
  • BTreeMap/BTreeSet pay the ordering/de-duplication cost once on construction; hashing then iterates already-canonical and is O(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:

FileProvesProtects you from
.sha256integrity — the bytes arrived intacta corrupted download
.ascauthenticity — the file is genuinely ourssomeone 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 / .zip on 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. LedvarLedger · 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.