Skip to content

Instantly share code, notes, and snippets.

@ChristopherA
Last active August 31, 2026 00:10
Show Gist options
  • Select an option

  • Save ChristopherA/016ced1645da8bfdb576e5b8492c4c38 to your computer and use it in GitHub Desktop.

Select an option

Save ChristopherA/016ced1645da8bfdb576e5b8492c4c38 to your computer and use it in GitHub Desktop.
A redactable append-only log: an audit record separate from live state, with a digest chain, wrap-then-sign entries, and elision that preserves the digest so redacted entries still verify. A stack-agnostic extraction over Gordian Envelope and deterministic CBOR.

A Redactable Append-Only Log

A design for an append-only event log whose entries can be redacted, and disclosed to different readers at different depths, without breaking verification. It is described so that it can be implemented in any language on any platform.

The design rests on four practices: the log is a truth separate from the application's live state rather than the source that state is rebuilt from, redaction preserves the chain instead of breaking it, one committed entry serves every audience at a different depth, and the format's version moves when meaning moves rather than when fields are added.

Scope

This is a design for logs of one shape rather than for logs in general, and the shape is what decides whether it applies. Entries are events attributed to a principal. They accumulate in order and are not edited afterwards. Some of them carry content that only a subset of readers may see. Some of that content has eventually to be destroyed, on request or on a schedule, while the fact that the event happened remains part of the record.

Logs with that shape are common and are not usually recognized as one another: audit and deployment records, clinical notes, editorial history, custody chains, agent transcripts, and conversation, which the appendix works through in full. What they share is the collision this design exists to resolve, between a record that must stay verifiable and content that cannot stay forever. A log whose content is never restricted and never destroyed has little use for what follows. Keeping the log separate from the state it describes, the first practice below, still pays there; the rest is machinery for a problem it does not have.

The encoding beneath this design is neither new nor specific to it. Entries are Gordian Envelope structures serialized as deterministic CBOR, both published specifications with a reference implementation that this system's output is checked against. What is specified here is the profile written over that stack, together with the practices that came out of operating it.

Storage, rotation, and retention are out of scope. So is behaviour at scale: the claims here are correctness claims about mechanisms.

Provenance and evidence

This design is extracted from eOS-ChatTheatre, a public streaming server whose event log is its audit and content record.

Section 5 classifies every claim by the evidence that carries it: a passing test vector, an acceptance gate that verifies the running server's own bytes with an independent decoder, or neither. The third class is short and it is not empty. The most significant item in it is the signing posture, which is stated here rather than deferred: entries are appended unsigned, and the signature slot is proven by vectors rather than by production traffic. The same class carries the one defect recorded here that no later work closes: an entry committed before decorrelation was in place stays open to recovery for as long as it exists.


Part I — Practices

Each practice states what to do, the failure it prevents, and what adopting it costs. The failure is the portable part, because implementations differ and failure modes recur.

BP-1: Keep the log as the audit truth, separate from the state it describes

Practice. Live state is authoritative for what is true now. The log is authoritative for what happened. Neither is derived from the other, and reads divide accordingly: present state answers questions about now, and the log answers questions about the past.

Rationale. The failure has two versions, and the second resembles good architecture.

The first version is having no log. Current state is the only record, so what happened must be inferred from what is true now. History is only as good as the last write, the question of who changed something and when requires an audit table that the same code can rewrite, and a bug that corrupts state corrupts the history of that state in the same instant, because they are the same bytes.

The second version makes the log the source of truth that state is replayed from. Once the present is a function of the log, every entry is load-bearing for correctness and removing one changes what replay produces. Redaction becomes a correctness question rather than a policy question, which produces tombstones, compensating entries, and eventually a rule that nothing is ever really removed. A system that promised an audit trail has then committed to permanent retention of everything anyone put into it.

Cost. Two records exist that can disagree, so the design requires a single writer path that appends as it mutates, and a discipline about which record answers a given question.

Consequence. The log's content is not load-bearing for correctness, which is the property that makes BP-2 affordable. Content can be destroyed permanently without anything downstream becoming incorrect.

Limit. This practice does not by itself make a log tamper-proof, and that is the most commonly overclaimed property in this area. A hash chain held end to end by one party is tamper-evident, and only to a reader who already holds a digest from earlier. A server that rewrites its own log from the beginning produces a chain that is perfectly self-consistent. Three mechanisms address it, and they answer different questions: a signature over an entry makes it attributable independently of the server, a digest published outside the server, in a permalink a reader retains or an attestation a third party holds, makes a rewrite detectable, and a chain of provenance marks makes insertion and reordering detectable without involving anyone else. Section 4.8 specifies how each attaches. An implementation should be explicit about which of the three it has enabled.

BP-2: Build entries as digest trees so redaction preserves verification

Practice. An entry is a tree of digests rather than an opaque record. The content, each field, and each field's value are separately addressable by their own hash, and the entry's identity is the root of that tree, computed from its parts. Redaction replaces an element with its own digest.

Rationale. A log built as a hash chain over opaque serialized records has one way to remove content, which is to delete or overwrite the record. That changes its digest, breaks the chain from that point forward, and voids any signature over it. Systems facing this either retain everything permanently, and so cannot honour a removal request or keep restricted content out of an export, or they accept that redaction voids verification, after which the chain is ornamental because nobody verifies anything.

Replacing an element with its digest leaves the tree's shape intact. The root is therefore unchanged, the entry's identity is unchanged, and both its chain position and any signature over it survive. A verifier who never held the removed content, and never will, can still check that entry and every entry after it.

Consequence. Whether content can be deleted stops being a question about whether the audit trail survives.

BP-3: Commit one entry and project it per reader

Practice. Commit a single entry and produce per-reader views at read time. A reader without clearance for an element receives the same entry with that element elided: same identity, same chain position, and explicitly marked as withheld.

Rationale. Storing one copy per audience means two records that can drift apart, two paths where an access check can be wrong, and no way to demonstrate that the two describe the same event.

The subtler failure affects readers who may not see something. If a restricted entry is omitted from their history, the omission is itself a disclosure: either they observe a gap they cannot account for, or they observe nothing and are confidently wrong about what happened. Absence and withholding are different facts and should not render identically. Because projection preserves identity, two readers at different depths can compare notes and establish that they hold the same event.

Consequence. How deep to elide, for whom, and for which kind of event is a policy choice that lives outside the format. Changing what is withheld from a class of reader changes no committed byte and requires no format change. This matters because disclosure policy is the part of such a system most likely to be revised once real people use it.

Distinguish two acts. A projection is a read: temporary, per audience, and the full entry remains. A redaction is a write: permanent, applied to the stored entry, and the content is destroyed for everyone including the operator who ran it. Building both on elision means destructive removal is not a separate mechanism that must be got right independently, and an implementation can make it irreversible by construction.

BP-4: Move the version when meaning moves

Practice. Within a version, a reader accepts fields and vocabulary values it does not know, verifies such entries normally, renders them generically, and fails closed by keeping any entry it cannot classify out of disclosure-sensitive views. An explicit list bounds what that tolerance covers.

Rationale. The failure runs in both directions and most systems choose one.

Versioning on every addition means an optional field that no existing reader needed invalidates deployed readers for logs they could have read. The predictable adaptation is that people stop adding fields and begin overloading existing ones, which is the same change without the announcement.

Never versioning means that eventually somebody changes what an existing field means while its name and type stay put. This outcome is worse: every committed entry still decodes cleanly, now says something false, and nothing signals it.

The bounded list is the load-bearing part. Tolerance never covers the entry form, the chain rule, the set of facts the root entry declares, any change to what an existing field means, or new values in the vocabularies that decide disclosure. Each of those mints a new version. The root entry declares which version the log opened under, and a reader meeting a version it does not know refuses rather than guessing. Committed logs are never rewritten to match a later version.

One decision to make before the first production entry. Field names can be written as plain strings, or as short codes assigned by a registry that both sides consult. Codes are smaller on the wire. Strings cost bytes and carry no registry dependency, which matters when the vocabulary belongs to one application rather than being of general interest. Because digests commit to the choice, switching later changes every digest in the log, which makes it a new version of the entire format applied to a corpus that cannot be recomputed. Decide it while the log is empty.


Part II — Normative requirements

Requirements use MUST, MUST NOT, SHOULD, and MAY in the usual sense. This part is what a second implementation is checked against.

The sections below carry a worked example: a two-entry deployment log for a fictional service, assembled with the reference command-line implementation of the encoding layer and shown in envelope notation, the format's own textual rendering. Every fragment is the verbatim output of the command beside it, executed while this document was written. The example signs its entries with a throwaway key minted for the purpose, because the fragments demonstrate signature properties. That matches the posture of the vectors rather than of the production log, as section 4.4 records.

3. Two layers, and where to cut

Two layers with independent lifetimes are present, and only the upper one belongs to the implementer.

The encoding layer is published, specified, and has implementations available: Gordian Envelope for structure, over deterministic CBOR for bytes. It provides canonical serialization, the digest tree, and the elision mechanics. BP-2's argument is a property of this layer rather than an invention of the profile above it, since eliding an element without invalidating the digest tree is what the format was designed for. Its version moves when its specifications move.

The profile layer belongs to the implementer. It defines what an entry means: which fields exist, what their values range over, what the chain rule is, what the root entry declares, and what the disclosure policy withholds from whom. Its version moves when that meaning moves.

Cutting between them allows reuse of work that is easy to get subtly wrong: canonicalization, digest-tree construction, and elision. Merging them produces two recognizable symptoms: a format version bumped because an optional field was added, and a question about whether a redaction is legal that has an answer at the byte level. Neither question should be able to reach the encoding layer.

A test for the seam: the profile should be fully describable without reference to how bytes are laid out, and the encoding layer should be describable without a single noun from the application.

3.1 Terms

  • Entry — the unit appended to the log, chained, and identified. It is not the same object as the event it carries.
  • Event — the content and its fields, before being wrapped into an entry.
  • Subject — the thing an envelope is about. For an event, the content itself.
  • Assertion — a predicate and object pair attached to a subject, equivalent to a field in other formats.
  • Digest — the root of an element's digest tree, computed over its canonical bytes.
  • Elision — replacing an element with its digest, leaving every digest above it unchanged.
  • Projection — a per-reader view of an entry, produced by elision at read time.
  • Root entry — the first entry of a chain, declaring what the chain covers and which profile version it opened under. Also called genesis.

4.1 The encoding layer

Serialization MUST be deterministic, because digests are computed over the serialized bytes and any encoder freedom forks them between implementations. Deterministic CBOR is used here, specified as an application profile over CBOR's own determinism rules.

An entry's structure is a subject plus a set of assertions, where every element is addressable by its own digest and the whole forms a Merkle-like tree. This structure is what makes elision possible.

The tree for the worked example's first event, in the reference implementation's tree view, with every element carrying its own digest:

$ envelope format --type tree "$EVENT"
c45e0bd0 NODE
    6c639e25 subj "Deployed build 4112 to production."
    067f6722 ASSERTION
        f35a9745 pred "prev"
        589d50d4 obj Digest(20daa128)
    81d4d5bd ASSERTION
        29c09059 pred "author"
        c90eb8af obj "alice"
    9f867236 ASSERTION
        7df580be pred "time"
        3c17341c obj 2026-08-21T09:14:00Z

Assertion ordering MUST be by raw assertion digest, ascending, and that ordering MUST be used for both the serialized form and the digest computation. Two orderings, or an ordering by predicate name, produce entries that verify locally and fail against any other decoder.

The tree above shows that ordering: its assertions run 067f, 81d4, 9f86, ascending by assertion digest. Notation fragments elsewhere in this document order the same assertions differently for display, and the tree shows the committed order.

An implementation that uses an existing encoding-layer library does not need the remainder of this section. For an implementation writing its own encoder, which is reasonable on a platform without a library, three digest-image asymmetries are where it will diverge, and each produces digests that look plausible and match nothing.

  • A leaf's digest is computed over the content's bytes without the tag its serialized form carries.
  • A registry-assigned constant's digest is computed with a tag its serialized form omits.
  • An elided element's digest is its own digest verbatim, hashed no further, and its serialized form is a plain byte string rather than a tagged digest value.

A reimplementation SHOULD be checked byte for byte against a reference implementation before any log is committed, because digests commit every one of these choices into every entry already written.

4.2 Entry structure

The event's subject is the content. Everything else is an assertion on it.

Five predicates carry across applications and form the portable core: author, the principal the event is attributed to; time; prev, the chain link described in section 4.5; inReplyTo, an optional digest naming an earlier entry; and audience, zero or more principals for whom a restricted entry is intended. Every other predicate belongs to the application's vocabulary, which the profile enumerates.

Assembled with the reference implementation and rendered in notation, with $PREV holding the root entry's digest:

$ EVENT=$(envelope subject type string "Deployed build 4112 to production.")
$ EVENT=$(envelope assertion add pred-obj string author string "alice" "$EVENT")
$ EVENT=$(envelope assertion add pred-obj string time date "2026-08-21T09:14:00Z" "$EVENT")
$ EVENT=$(envelope assertion add pred-obj string prev digest "$PREV" "$EVENT")
$ envelope format "$EVENT"
"Deployed build 4112 to production." [
    "author": "alice"
    "prev": Digest(20daa128)
    "time": 2026-08-21T09:14:00Z
]

An implementation MUST treat the predicate set as open in the direction the version rules allow, and MUST NOT assume the five above are the only ones present.

Whether predicates are written as strings or as registry-assigned constants is decided before the first production entry, as BP-4 describes. Strings are used here, because the vocabulary belongs to one application and a registry dependency buys compactness at a cost paid permanently.

4.3 Entry form: wrap, then sign

The entry is the event wrapped: the whole event becomes the subject of an outer envelope, and that wrapper carries a signature assertion. Entries MUST be wrapped.

A signature over an unwrapped event covers its subject alone, which allows assertions to be added or removed while the signature still verifies. Wrapping places the complete event, subject and all assertions together, under the signature. Wrapping also keeps elision and signing independent: eliding an element inside the event leaves the wrapper's digest unchanged, so the signature over it still verifies.

Signing the bare event demonstrates the failure, with the event's fields outside the signature:

$ LOOSE=$(envelope sign -s "$PRVKEYS" "$EVENT")
$ LOOSE=$(envelope assertion add pred-obj string severity string "routine" "$LOOSE")
$ envelope verify -v "$PUBKEYS" "$LOOSE" > /dev/null && echo "verifies with a field added under the signature"
verifies with a field added under the signature
$ LOOSE=$(envelope assertion remove pred-obj string author string "alice" "$LOOSE")
$ envelope verify -v "$PUBKEYS" "$LOOSE" > /dev/null && echo "verifies with the author gone"
verifies with the author gone

An attribution that detaches while the signature continues to verify is the failure in full. Wrapping first places everything under the signature:

$ ENTRY1=$(envelope sign -s "$PRVKEYS" "$(envelope subject type wrapped "$EVENT")")
$ envelope format "$ENTRY1"
{
    "Deployed build 4112 to production." [
        "author": "alice"
        "prev": Digest(20daa128)
        "time": 2026-08-21T09:14:00Z
    ]
} [
    'signed': Signature
]

The same edit is then fatal. Re-attributing the event inside the wrapper and carrying the original signature over causes verification to refuse, because the signature covers the wrapper's digest and the wrapper's digest covers everything within:

$ SIG=$(envelope assertion find predicate known signed "$ENTRY1")
$ INNER=$(envelope extract wrapped "$ENTRY1")
$ TAMPERED=$(envelope assertion remove pred-obj string author string "alice" "$INNER")
$ TAMPERED=$(envelope assertion add pred-obj string author string "mallory" "$TAMPERED")
$ TAMPERED=$(envelope assertion add envelope "$SIG" "$(envelope subject type wrapped "$TAMPERED")")
$ envelope verify -v "$PUBKEYS" "$TAMPERED" > /dev/null 2>&1 || echo "verification fails"
verification fails

4.4 Signing posture

The log described here appends entries unsigned. The server deliberately holds no signing secret belonging to the people whose events it records, and per-entry signing depends on a key design that has not shipped.

The signature slot is proven by the vectors and by the acceptance gate, both of which sign with a test key, and every property claimed for it holds on those. An implementer with signing keys available SHOULD sign at append time. An implementer building this architecture should treat this half as a proven mechanism rather than a running one.

4.5 Identity and the chain

An entry's identity is the digest of the entry as appended, wrapper included. This identifier MUST serve every purpose that names an entry: the chain link, the reply target, the sequence anchor, and any external permalink. A second identifier space is a liability, because the two can disagree about which entry they name.

The signature is inside that digest. Under a randomized signature scheme this has a consequence worth knowing before anything is cached: signing the same event twice produces two different identities. The identity exists once the entry is appended.

The first entry's identity, as every entry after it will carry it:

$ envelope digest --hex "$ENTRY1"
111a53b4789e15794be49c09feb4bef51875175fad7f71886c23ce97d180c4dc

Every entry MUST carry prev, naming the previous entry's identity. There is no exception for the first ordinary entry: it names the root entry, so every entry has a predecessor and the chain has exactly one origin.

The worked example's second entry is assembled like the first, carrying that digest as its prev, which the notation abbreviates to the first four bytes. It also carries an audience, used in section 4.6:

$ ENTRY2=$(envelope sign -s "$PRVKEYS" "$(envelope subject type wrapped "$EVENT2")")
$ envelope format "$ENTRY2"
{
    "Rolled back 4112: the crash traces to the payments module." [
        "audience": "carol"
        "author": "bob"
        "prev": Digest(111a53b4)
        "time": 2026-08-21T09:41:00Z
    ]
} [
    'signed': Signature
]

The chain rule is a checkable equality rather than a convention:

$ PREV_OF_2=$(envelope extract digest "$(envelope extract object "$(envelope assertion find predicate string prev "$(envelope extract wrapped "$ENTRY2")")")")
$ test "$PREV_OF_2" = "$(envelope digest "$ENTRY1")" && echo "entry 2's prev equals entry 1's digest"
entry 2's prev equals entry 1's digest

The root entry MUST declare what the chain covers and which profile version it opened under. It is itself an entry, formed and chained like any other. A reader meeting a profile version it does not know MUST refuse the log rather than interpret it. Committed logs are never rewritten to a later version.

A log MAY be partitioned, with one chain per scope, each chain rooting independently. Where it is, entry identity remains globally unique because it is a digest, so an identifier resolves without knowing which partition holds it. Chains SHOULD root lazily, on first append rather than on creation, so an empty scope costs nothing.

4.6 Elision

Two distinct operations share this mechanism and MUST be distinguished in the implementation, because one is reversible and the other must not be.

Projection is a read. The stored entry is untouched, and a reader without clearance receives a form of it with elements elided. Projection MUST be enforced on every read path, including live delivery, replay after a disconnect, and history queries. Enforcing it on some paths is equivalent to enforcing it on none.

Redaction is a write. The stored entry becomes the elided form permanently and the content is destroyed for every reader. A redaction MUST leave no undestroyed copy anywhere the application controls, including the projection metadata described below. It is the one operation here that cannot be undone, and an implementation SHOULD make that structural rather than a matter of policy.

Both MUST preserve the entry's identity. An elided entry's digest equals the full entry's digest, its position in the chain is unchanged, and a signature over it still verifies.

In the worked example, the second entry's content is restricted to its audience, so any other reader receives it withheld. Eliding addresses the content by its digest:

$ WITHHELD=$(envelope digest "$(envelope subject type string "Rolled back 4112: the crash traces to the payments module.")")
$ PUBLIC=$(envelope elide removing "$WITHHELD" "$ENTRY2")
$ envelope format "$PUBLIC"
{
    ELIDED [
        "audience": "carol"
        "author": "bob"
        "prev": Digest(111a53b4)
        "time": 2026-08-21T09:41:00Z
    ]
} [
    'signed': Signature
]

That is the shallower of two withholding depths: content withheld, attribution and chain position public. The deeper form elides more elements by the same operation. Identity, chain position, and signature survive it, and two commands check both halves:

$ test "$(envelope digest "$PUBLIC")" = "$(envelope digest "$ENTRY2")" && echo "digest unchanged"
digest unchanged
$ envelope verify -v "$PUBKEYS" "$PUBLIC" > /dev/null && echo "signature verifies on the elided form"
signature verifies on the elided form

A restricted entry MUST be visibly withheld rather than silently omitted from a reader's history. What is withheld MAY differ by kind of event, and this is where disclosure policy lives: one class of restricted event might withhold only its content while remaining attributable, and another might present as nothing but its slot in the chain. Both are the same entry, and neither needs a format change to become the other. Withholding an element does not by itself make its value unguessable; section 4.7 states what does.

Implementations SHOULD hold the fields a projection reasons over as ordinary application state beside the entry, rather than parsing the committed bytes on every read. This permits a further discipline worth adopting deliberately: the elided form is rebuilt from that state rather than edited out of the stored bytes, and the rebuilt entry's digest is compared against the committed one before anything is served or destroyed. A divergence aborts with nothing mutated. Every export and every redaction becomes a self-test of the elision implementation at the cost of one comparison, which catches a failure that otherwise surfaces years later as an entry nobody can verify.

That discipline creates an invariant for whoever adds the next field: every assertion an append path can produce MUST have a matching arm in the rebuild path, or the first redaction of an entry carrying it fails the digest check.

4.7 Decorrelation

Elision replaces an element with its digest, and that digest travels with the projection. Where the element's value is drawn from a set a reader can enumerate, the reader hashes candidates until one matches, and the withheld value is recovered. Rosters of principals, timestamps to the second, vocabulary values from a published list, and short messages are all such sets. The set has to be small only in practice.

The recovery is one comparison against the projection built in section 4.6:

$ ELIDED=$(envelope format --type tree -d full "$PUBLIC" | awk '/ELIDED/ { print $1 }')
$ GUESS=$(envelope digest --hex "$(envelope subject type string "Rolled back 4112: the crash traces to the payments module.")")
$ grep -qxF "$GUESS" <<< "$ELIDED" && echo "withheld content recovered from a guess"
withheld content recovered from a guess

Every element an implementation may elide MUST therefore carry its own salt: random bytes attached to that element when the entry is constructed, before the signature is applied. Salt MUST be freshly generated per element and MUST NOT be reused across elements or across entries. Salt length SHOULD vary per element, so that the size of a salted element discloses nothing about the value beneath it. The chain link is excepted: it is never elided, and its correlatability from one entry to the next is what makes the log a chain.

Salt is chosen at construction and cannot be applied afterwards. It changes every digest above the element it decorrelates, including the entry's identity, so an entry already committed cannot receive it. An entry sealed without salt stays recoverable for as long as it exists, and no change to the export path, the projection policy, or the reader reaches back to it. An implementation decides this before the first entry it cares about, and a log already in service carries the exposure permanently for everything appended before the decision.

Where the element being salted is a subject, the salted node MUST be wrapped. A salted node used directly as another node's subject reads back as a single flattened node, with the salt beside the event's own assertions:

$ envelope format "$(envelope salt "$(envelope assertion add pred-obj string author string "bob" "$(envelope subject type string "Rolled back 4112: the crash traces to the payments module.")")")"
"Rolled back 4112: the crash traces to the payments module." [
    "author": "bob"
    'salt': Salt
]

The content's own digest is unchanged by that salt, so eliding the content removes nothing a reader could not recompute. Both of the properties an implementer checks after such a change continue to hold, and the projection displays a salt it is not benefiting from:

$ FLAT=$(envelope sign -s "$PRVKEYS" "$(envelope subject type wrapped "$(envelope salt "$(envelope extract wrapped "$ENTRY2")")")")
$ FPUBLIC=$(envelope elide removing "$WITHHELD" "$FLAT")
$ test "$(envelope digest "$FPUBLIC")" = "$(envelope digest "$FLAT")" && echo "digest unchanged"
digest unchanged
$ envelope verify -v "$PUBKEYS" "$FPUBLIC" > /dev/null && echo "signature verifies on the elided form"
signature verifies on the elided form
$ grep -qxF "$GUESS" <<< "$(envelope format --type tree -d full "$FPUBLIC" | awk '/ELIDED/ { print $1 }')" && echo "withheld content recovered from a guess"
withheld content recovered from a guess

Wrapping the salted node first makes it the subject of its own envelope, so the digest standing in for it commits to the salt:

$ SALTED=$(envelope subject type wrapped "$(envelope salt "$(envelope subject type string "Rolled back 4112: the crash traces to the payments module.")")")
$ envelope format "$SALTED"
{
    "Rolled back 4112: the crash traces to the payments module." [
        'salt': Salt
    ]
}

Building an entry on that subject leaves identity and signature intact and separates the third check from the first two:

$ SEVENT=$(envelope assertion add pred-obj string author string "bob" "$SALTED")
$ SEVENT=$(envelope assertion add pred-obj string audience string "carol" "$SEVENT")
$ SENTRY=$(envelope sign -s "$PRVKEYS" "$(envelope subject type wrapped "$SEVENT")")
$ SPUBLIC=$(envelope elide removing "$(envelope digest "$SALTED")" "$SENTRY")
$ test "$(envelope digest "$SPUBLIC")" = "$(envelope digest "$SENTRY")" && echo "digest unchanged"
digest unchanged
$ envelope verify -v "$PUBKEYS" "$SPUBLIC" > /dev/null && echo "signature verifies on the elided form"
signature verifies on the elided form
$ grep -qxF "$GUESS" <<< "$(envelope format --type tree -d full "$SPUBLIC" | awk '/ELIDED/ { print $1 }')" || echo "the guess no longer matches the withheld element"
the guess no longer matches the withheld element

A reader with clearance unwraps twice to reach the content, once for the entry and once for the salted subject:

$ envelope extract string "$(envelope extract wrapped "$(envelope extract wrapped "$SENTRY")")"
Rolled back 4112: the crash traces to the payments module.

Adding decorrelation to a profile already in service is a new version. The tolerance rule in section 4.9 covers predicates and vocabulary values a reader does not know. It does not cover the shape of a content read, which resolves a bare subject and does not resolve a wrapped one. An implementation MUST move the version when it adopts this, and MUST NOT append a decorrelated entry to a chain whose root entry declares a version without it, because one chain carrying both shapes makes its own root declaration false.

Cost. Each salted element grows by its salt and the predicate carrying it, on the order of twenty bytes. In the implementation this document is extracted from, a representative entry with eight salted elements grew from 486 to 822 characters of its serialized form.

4.8 Attestation

A chain held end to end by one party is internally consistent by construction, which means its consistency is not evidence of anything. Three questions stay open, and each has its own mechanism, its own cost, and its own edge. All three attach to an entry as ordinary assertions and none of them changes the entry form, so an implementation may adopt any one separately, at any time, without a new profile version.

Who: a signature. A signature over the wrapper attributes the entry to a key, as section 4.3 specifies and section 4.4 records for this implementation. Its edge is that it binds an entry to a key rather than to a person, and it establishes nothing about when the entry was made.

When: a digest published outside the server. The time assertion inside an entry is the writer's own claim, and a server that rewrites its log rewrites that claim with it. Publishing the entry's digest somewhere the writer cannot reach, in a permalink a reader retains, a timestamp authority's token, or a blockchain transaction, makes a later rewrite detectable. Only the digest travels, so this discloses nothing about the content. Its edge is direction: an anchor establishes that the entry existed no later than the moment it was published, and never that it did not exist earlier, so it bounds backdating in one direction only. It is also the one mechanism here that requires a third party.

Order: a chain of provenance marks. A provenance mark commits to the key of the mark that will follow it, and the successor reveals that key. Recomputing the earlier mark's hash from the revealed key is what proves the two are adjacent, so a mark cannot be placed between two that are already published, and a successor cannot be produced by anyone lacking the generator's state. Insertion and reordering are therefore detectable with no witness service involved. A mark is carried as an assertion like any other, with $MARK holding a mark produced by such a generator:

$ envelope format "$(envelope assertion add pred-obj string provenance ur "$MARK" "$(envelope subject type string "Deployed build 4112 to production.")")"
"Deployed build 4112 to production." [
    "provenance": ProvenanceMark(d2ff0ca2)
]

Its edge is that it orders a chain against itself rather than against a clock. The date inside a mark is the generator's claim, exactly as the entry's own time is the writer's, and a chain produced privately and released all at once demonstrates only that it is internally consistent. Marks bind an order to elapsed time to the degree that they were published as they were made, where others could see them.

An implementation MUST state which of the three it has enabled, and none of them implies another: a signature with no anchor leaves a rewrite undetectable, an anchor with no signature leaves an entry unattributable, and marks with neither order a sequence nobody can attribute or date. An implementation that has enabled none of the three MUST NOT describe its log as tamper-proof. The accurate claim in that case is the one section 5.4 makes here, that the chain is internally consistent and externally unattested.

4.9 Versioning and tolerance

Within a major profile version, a reader MUST accept entries carrying predicates and vocabulary values it does not know. It MUST verify them normally, since chain and signature mechanics do not depend on understanding any field. It SHOULD render them generically. It MUST fail closed: an entry it cannot classify MUST NOT enter any projection whose purpose is deciding who may see what.

Tolerance MUST NOT extend to the entry form, the chain rule, the root entry's declared set, a change in what an existing predicate means, or new values in any vocabulary that decides disclosure. Each of those is a new profile version.

4.10 Invariants

The following MUST NOT hold in a conforming implementation. The list is the fastest way to check one.

  • No entry exists without a prev, and no chain has two origins.
  • No entry's identity is anything other than the digest of the entry as appended.
  • No elided form has a different digest from the entry it came from.
  • No restricted entry is silently absent from a reader's history rather than visibly withheld.
  • No redacted content survives anywhere the application controls.
  • No read path serves an unprojected entry.
  • No unclassifiable entry reaches a disclosure-sensitive view.
  • No withheld element is recoverable, by a reader without clearance for it, from the digest that stands in for it.
  • No committed log is rewritten to satisfy a later profile version.

Part III — Evidence

This part records what carries each claim. Readers deciding how much of this design to trust should read it first.

The balance is uneven in an unusual direction and is stated before the detail. The format claims are the strongest material in this document, verifiable by a stranger with one command-line tool and no access to anything belonging to this project. The signing claims are the weakest, and one of them is not running.

Everything cited below is public, at github.com/eOSContinuum/eOS-ChatTheatre: the profile in docs/design/envelope-profile.md, the vectors in docs/design/envelope-vectors/, and the verification script in scripts/.

The command fragments in Part II are none of these. They are illustrations, generated against the reference implementation while this document was written and signed with a throwaway key. The committed vector set is separate and unchanged by them.

5.1 Carried by test vectors

A committed vector set verifies offline against the reference implementation of the encoding layer, with no network, no server, and nothing regenerated. Running it requires one tool and a checkout: 50 checks pass and none fail, 13 over the ancestor profile and 37 over its successor. Five further checks stand beside those 50 and are recorded as known defects. They ask whether a withheld element is actually unrecoverable, one for each projection that withholds a value, and all five fail, for the reason section 4.7 gives: the vectors were sealed before decorrelation was specified, and no later change reaches an entry already committed. The suite prints them, stays green while they fail, and turns red if one ever passes, so the marker cannot outlive its subject. That run was repeated while this document was written rather than quoted from a previous one.

The checks establish the following, ordered by significance to a reimplementer.

Elision is digest-stable and signature-stable across four independently constructed shapes: the withheld-content form of a restricted entry, a single field withheld while its neighbours remain visible, a structural view that keeps some fields and drops others, and a maximally elided entry with everything gone but the chain link. In each of the four, the elided entry's digest equals the full entry's digest and the signature over it still verifies.

The fourth shape is the one to study when building this. It withholds every element but the chain link, including the field naming what kind of event it was, and the chain still verifies through it. A format whose withheld entries are self-identifying leaks the shape of what it is hiding.

What these vectors do not establish is that a reader without clearance learns nothing from such an entry. Withholding is not concealment: each withheld element is replaced by its own digest, and a reader who can guess the value confirms the guess against that digest. Against these vectors the guess succeeds, which is what the five known defects record. Section 4.7 specifies the mechanism that closes it and section 5.4 states what it does not reach.

The remaining checks establish that every entry's prev equals the prior entry's digest, across a three-entry log and an eight-entry one; that every entry verifies against the test signer, root entry included; that the full form of a restricted entry shows its content while the withheld form does not and is explicitly marked elided; that a reply reference resolves to an earlier entry's identity; and that the root entry pins the profile version string.

5.2 Carried by the running system, checked by an independent decoder

Vectors are fixtures, and a fixture proves only what its author thought to write down. The stronger evidence is an acceptance gate that drives real sessions against a running server over HTTP and live streams, restarts the server mid-run, exports the server's own log bytes, and hands them to the reference command-line implementation. The bytes are checked by something other than the code that produced them. That suite was run while this document was written: every gate passed, including the format gate.

Over those live bytes, the gate establishes the digest chain across the exported span, and establishes that the server's own withheld export and the full entry share one digest, with the content present in one and absent-and-marked in the other.

The gate also establishes the elision property on a live entry, with an asymmetry that requires stating. Because entries are appended unsigned, the gate signs an exported live entry with the test key, then elides the content, then checks that the digest is unchanged and the signature still verifies. The entry is real and the signature is synthetic.

Over the same exported bytes the gate checks decorrelation as section 4.7 requires it. The digest standing in for the withheld content does not match the digest of that content guessed exactly, and the element the projection withheld is the salted wrapper rather than the content leaf inside it. Salt is applied when the server seals the entry, so both are properties of the exported bytes rather than of the tooling that reads them.

The independence is what makes this stronger than the vectors. The encoder here was written from scratch, on a platform with no library for this format, from the specifications and the three digest-image asymmetries named in section 4.1. An implementation that nobody involved in writing it controls reads its output and agrees, which is the evidence that the encoding layer is portable in practice rather than in principle.

5.3 Carried by running code

The separation of the two truths is enforced where it would be easy to fudge. A reset that clears what the shared view shows its readers moves a presentation floor and does not touch the log: replay, the chain, and every other read still see the entries that reset skipped. The record is not what the interface currently displays, and the code reflects that at the point where a shortcut would be tempting.

Projection is applied on every read path without exception. Live delivery, replay after a disconnect, recent history, and a span read all route through one projection function, and no read path reaches the stored entries around it.

Two withholding depths are chosen by kind of event, which makes BP-3's point about policy living outside the format concrete. One class of restricted event withholds its content and remains attributable, because the fact that it happened is public. Another presents as nothing but its slot in the chain.

The rebuild-and-check discipline runs on both export and destructive redaction: the elided form is rebuilt from application state rather than edited out of the committed bytes, its digest is compared against the committed entry, and a divergence aborts before anything is written or served. Redaction destroys the content in place and leaves no undestroyed copy.

Chains root lazily, one per partition, and entry identity remains globally unique across partitions because it is a digest.

5.4 Carried by neither

Production entries are unsigned. The server holds no signing secret belonging to the people whose events it records, by design, and per-entry signing depends on a key architecture that has not shipped. Everything claimed for the signature slot holds on vectors and on test-signed live entries. Nothing claimed for it is running. An implementer with signing keys available should sign at append time.

None of the three attestation mechanisms is enabled. Section 4.8 names them and requires an implementation to say which it has running. This one has none. WHO is unestablished because entries are appended unsigned, as the item above records. WHEN is bounded by nothing but the server's own time assertion, since no entry digest is published anywhere the server cannot reach. ORDER rests on the prev chain alone, and a server rewriting its log from the beginning reproduces that chain perfectly, which is the failure BP-1's Limit describes. The chain is internally consistent and externally unattested. That bounds which interactions the log can support. In the progressive trust lifecycle it sustains the phases through Fulfillment and forecloses the two that require a third party: Escalation, in which an independent party inspects the record, and Dispute, in which an arbitrator resolves a conflict from it. Both need evidence the server did not itself produce, which is what all three mechanisms in section 4.8 exist to supply.

Entries committed before decorrelation remain correlatable. Salt cannot be added to an entry already sealed, so every entry appended before the mechanism in section 4.7 was in place stays open to the recovery demonstrated there, for as long as it exists. The committed vectors are such entries, which is what their five known defects record. This is the one defect in this document that no later work closes; it is bounded by discarding the affected log rather than by repairing it.

No vector covers the decorrelating version. The committed set covers two profile versions, and the implementation has since moved to a third that decorrelates. Vectors for it are not cut. The claims in section 4.7 are carried by the demonstration in that section and by the acceptance gate over live bytes in section 5.2, and not by the vector set.

Nothing here is proven at scale. Log growth, read latency under load, and concurrent append behaviour were not measured.

Storage, rotation, and retention are absent. They are not described, not tested, and not implied by anything above.

5.5 Adoption priority

Implementers adopting part of this design should take the separation in BP-1 first, because it is the one that cannot be added later. A log that state is replayed from has already foreclosed redaction, and no amount of digest work reopens it. The choice is made on the day the log's purpose is decided, usually without being recognized as a choice.

The second priority is wrapping before signing. Signing an event directly, without the wrapper, is the intuitive design, and it leaves assertions addable and removable under a signature that still verifies. It costs one extra layer to close and remains invisible until someone looks for it.

The third is decorrelation, which shares the property that makes the first two urgent: it is fixed when an entry is constructed and can never be applied to one already committed. It is also the easiest of the three to believe is already handled, because a projection that withholds an element and a projection that conceals it render identically, and the checks an implementer reaches for first pass in both cases. Every entry appended before it lands is permanently exposed, so the cost of deferring it grows with use.


Appendix — A chat as a log

Conversation is the log kind a reader is least likely to recognize as one. An audit trail is obviously a record; a conversation feels like traffic that happens to be stored. This appendix maps the practices onto it, because that is the case the design was extracted from and the case where each practice is easiest to feel. Nothing in Parts I or II depends on anything here. A reader implementing against those parts can stop at section 5.5.

An utterance is an entry. Someone says something, which is an event, attributed to them, at a time. It becomes one entry whose subject is the words and whose assertions are the author, the time, the chain link, and whatever else the application's vocabulary defines. Nothing in that structure is specific to speech, and the same shape carries a deployment, a diagnosis, or a custody transfer. What conversation adds is volume, and the fact that the people producing it experience it as ephemeral while the record is not.

Clearances are the audience. A message meant for some readers and not others is one entry carrying an audience assertion, committed once and projected per reader. The readers it names receive the words. Everyone else receives the same entry, in the same position in the chain, with the words elided. This is where BP-3's subtler half becomes concrete: an outsider learns that something was said and that they may not read it, which is a different fact from learning nothing at all, and the two must not render identically. A room where restricted messages simply vanish from other people's histories teaches those people to distrust the parts they can see.

Conversation also shows why withholding depth is a policy choice rather than a format one. A whisper across a room is a public act with private content, and the room sees that it happened. A direct message can be private as an act, presenting to everyone else as nothing but a slot in the chain. Both are the same entry structure, and neither needs a format change to become the other.

A reply is a digest. Threading names an earlier entry by its identity rather than by a position or a mutable row id, so a reply keeps resolving after the entry it answers has had its content destroyed. The shape of the conversation survives the removal of what it was about.

Order is the chain. Each entry names its predecessor's digest, so the sequence is fixed by the entries themselves. A sort over timestamps would leave the order in the server's hands. That is what lets someone who was not present check a transcript. It also bounds what a withheld entry gives away, because a reader without clearance still sees exactly where it sat, and this is why decorrelation matters more in conversation than in a log of machine events. Guessing what a colleague said in a room of six people is a far smaller search than guessing a deployment identifier, and section 4.7 is the difference between a withheld line and a concealed one.

Removal is redaction, and the transcript still verifies. Someone asking that their words be destroyed is routine in conversation, and it is the requirement that decides the architecture. Because the live state is not rebuilt from the log, destroying content breaks nothing downstream. Because entries are digest trees, destroying it leaves every other entry's chain link and signature intact. A reader who never saw those words, and now never can, still verifies the conversation around them.

A new kind of message is additive; a change to who may read is not. Adding a form of utterance introduces a vocabulary value that deployed readers tolerate and render generically. Changing what an existing clearance means is a version move, because it silently changes who may see entries that are already committed and cannot be recomputed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment