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.