ChatterLocks
A tool for publishing AI conversation transcripts with verifiable provenance and selective redaction.
Status: design specification, revision 0.11. Not implemented. Not audited.
0.11 changes direction. It adds a session notary: an outside service that witnesses a conversation while it happens, without being able to read it. That reverses the "no hosted service" non-goal the first ten revisions kept, so the reversal comes first, in What you are trusting, before anything else.
What you are trusting
Revisions up to 0.10 had nothing hosted. Readers trusted Bitcoin for dates and the author for everything else. 0.11 adds one party, the session notary, because of a gap no local tool can close (see Selection). This section says what that party is trusted with, what it never gets, and what happens when it fails.
What the notary is. A server that takes part in the encrypted connection between the author's machine and a model provider's API, in the style of TLSNotary. The author's client and the notary jointly hold the session keys, so neither alone can forge the provider's side of the conversation, and only the client can decrypt it. When the session closes, the notary signs a record of what it saw and appends it to a public log.
What it sees. That a session happened, which provider host it connected to, which ChatterLocks account asked, when each turn went out and came back, and how many encrypted bytes moved each way.
What it never sees. The words of the conversation, in either direction. The author's API key, which travels inside the encryption. Anything about readers.
What it is trusted to do. Sign honestly: to record sessions it took part in, with the times it observed, and nothing it did not take part in. A notary that colludes with an author could sign a record for a session that never happened. It could not supply the content of one, because it never held the plaintext.
What a compelled notary can hand over. The timing record above, per account. That is a detailed picture of when an author works, and the author is told so in those words before turning witnessing on. It cannot hand over transcripts, because it never had them.
What happens when it dies. Every log head is anchored outside the service, in OpenTimestamps and in Sigstore's Rekor, from the first record. Records already issued stay verifiable against those anchors with the notary gone. What stops is new witnessing. The notary has a written wind-down procedure: freeze the log, publish a final head, leave every record checkable.
What it does not fix. The notary witnesses only the sessions routed through it. An author can always talk to a model somewhere else. What it changes is that, for the sessions it witnessed, the author cannot decide afterwards which ones existed. See Session notarization.
Everything else in this document, including the local CLI, the commitments, redaction, disclosure, and offline verification, works as before and still works with no notary at all.
The problem
People increasingly want to show their work when they use AI to draft something: which prompts they ran, what the model said, where they pushed back. Call it a methods section for AI-assisted writing.
Two things block it.
First, a transcript contains material that shouldn't be public. Names, employers, locations, third parties who didn't consent, medical detail, profanity that changes how the argument lands. Publishing the raw log is not an option, and publishing a redacted log invites the obvious challenge: you cut the part where you told it what to conclude.
Second, the platform share link is the wrong primitive. It publishes a whole-conversation snapshot with no editing and no partial disclosure. It's all or nothing.
ChatterLocks separates the commitment from the disclosure. You optionally seal each conversation the moment it ends, commit publicly to all of it when you publish, publish only the redacted version, and reveal individual withheld passages later, on demand, to anyone who challenges a specific point. Each revealed passage verifies against the commitment you already published.
The design target is not a determined forger. It is that showing your work should cost less than faking it, so that a reader who is hostile to slop rather than to AI can tell the difference without taking anyone's word for it.
How it works, end to end
Nothing here is built yet. This is the intended flow, written out so the pieces can be judged as a whole before anyone implements them. Commands are from CLI surface.
The short version
The full flow below is what the tool does, not what a person has to do. In ordinary use:
chatterlocks init export.json # once, per conversation
chatterlocks seal <session> # stamps and witnesses it for you
chatterlocks redact <session> # the only part that needs your attention
chatterlocks commit <session> --into publication/
chatterlocks publish publication/ post.md --session <id> --witness archive
Stamping, completing pending proofs, appending to your index, and signing all happen inside those commands. Nothing has to be remembered later. Reading the conversation and deciding what to withhold is the one step that cannot be automated, and the one that matters.
Skipping seals entirely is fine. You lose the claim that the conversation predates the piece, and keep everything else.
Before you start
You need the conversation's export file from the platform, the text of whatever you are publishing, somewhere public to put the files, and a passphrase for the private workspace. Keep the passphrase somewhere you will still have it in a year: without it, you can never reveal a withheld passage.
While the conversation is still fresh
- Export the conversation. Use the platform's own export. If it only exports your whole account, say so at the next step and expect the warning about scope.
chatterlocks init export.json --unit paragraph. It shows what committing to a transcript costs you and asks you to acknowledge it, then parses, normalizes, and creates the private workspace. Nothing is public yet.chatterlocks seal <session>. This fixes the conversation in time before you have written anything about it, and prints a commitmentS.- Decide how you are signing this one. Attributable, under the identity readers know you by, is the default and the only mode that supports the claim that you are not sitting on twenty variants. Unattributed is there if being seen to work on this would cost you something. The choice is bound into the commitment, so make it now.
- Witness the seal, the same day. Put
Ssomewhere you cannot edit later: an archive capture of a public paste, a post on a platform you do not control. Thenchatterlocks witness add <S> <url>. An unwitnessed seal proves nothing. A witness needs a trustworthy date, not your name, so use an unattributable one if being seen to work on this would cost you. - Add the seal to your index, if you are using one, and if you go back to the conversation, run
chatterlocks init --continue <session> <new-export>and seal again.
When you are ready to publish
chatterlocks redact <session>. Go through every message. Mark each span to withhold, give it a kind and whose it is, and add substitute text where a word has to be shown but not the original. Answer the context prompts honestly: the tool shows you each withheld span in its surrounding text so you can see whether the gap gives itself away.chatterlocks commit <session> --into publication/. This prints the transcript commitmentTand writes the public files.- Put
Tin your piece. A footer line is enough:Transcript commitment: <T>. This is what lets a reader who archived your piece prove later which transcript you meant. chatterlocks publish publication/ artifact/0.md --session <id>. It checks your piece containsT, computes the artifact commitmentA_0, and timestamps it.- Witness
A_0too, the same way, andchatterlocks witness add. - Upload the
publication/directory next to your piece, and link to the verifier page so readers know what to do. Publish your piece's source text as well as the rendered version, or the binding cannot be checked. - A few hours later,
chatterlocks upgrade. Until the Bitcoin transaction confirms, the timestamp is only a promise from a calendar server, and the verifier will say so. - Sign and index it. Under an attributable identity, sign
artifact/0.preimageand runchatterlocks index add, so this commitment sits in the list readers can enumerate. Under the unattributed mode, skip this and expect the verifier to say the claim is weaker.
Afterwards
- Someone challenges a specific point.
chatterlocks disclose <session> <msg> <unit>produces a small bundle. Anyone can check it against the commitment you already published, withverify-disclosureor the verifier page. Treat every disclosure as publication: the bundle works for whoever holds it. - Someone you quoted objects.
chatterlocks withdrawremoves a published passage from your files. It cannot remove copies other people already have. - You decide you will never disclose.
chatterlocks forfeitdestroys the secret. Published passages stay checkable; withheld ones become permanently undisclosable, and a leak of your private file can no longer be authenticated.
What a reader does
- Download the
publication/directory, or open the static verifier page and give it the files. Everything needed is in there; nothing phones home, and the author does not learn you looked. - Run
chatterlocks verify publication/, or let the page do it. - Compare the commitment shown against the one printed in the piece, and against an independent copy: an archive snapshot, or their own earlier record. This step is the one that matters. A commitment taken only from the author's own files proves nothing about what the author committed to first.
- Read the per-passage statuses.
VERIFIEDmeans the text matches what was committed.HIDDENmeans something was withheld and the label is the author's word for it.SUBSTITUTEDmeans the words shown are the author's replacement.
What this does and does not prove
State this plainly in the README. Overclaiming is the fastest way to lose the trust the tool is trying to build.
Every guarantee below is relative to a published commitment value. It means something only if readers can establish that the value they are checking is the one the author published at the time. See Witnessing.
It proves:
- A specific transcript existed in a specific form before a specific Bitcoin block.
- Every published passage is byte-identical to the corresponding passage in the committed transcript, including its role and its place in the conversation.
- The redaction layout (which passages are withheld, their stated categories, any substitute display text, and the branch structure) was fixed at commitment time.
- A specific withheld passage was present in the committed transcript, at a specific position, when and if the author reveals it.
- The artifact source is the exact text committed alongside its transcripts, and each later revision is chained to the one before it.
- With an attributable identity: that every commitment made under that identity appears in one public list, so holding many variants of a transcript shows up as commitments made and never opened. See Attribution and the author index.
- For sealed sessions whose seals were witnessed promptly: every fully published unit (a paragraph or a whole message, depending on the seal mode) in the sealed part of the conversation, and every such unit later disclosed in full, existed word for word by the witness date of the earliest seal covering it.
It does not prove:
- That the conversation actually happened. An author could fabricate a transcript and commit to the fabrication. The commitment only fixes it in time.
- That the author disclosed everything relevant. Withheld passages stay withheld unless revealed.
- That the AI model actually produced those outputs, unless the session was notarized. No vendor attestation exists, and for an un-notarized session the stated model is the author's claim. A notarized session shows that the assistant turns arrived over an encrypted connection to the provider's host; it still does not show which model the provider ran. Some tools, including API consoles and local model front-ends, let the user write or edit assistant turns directly.
parsernames the source the export came from, and readers should weigh it. - That the transcript came from the platform. The export digest lets an author later show a reviewer the exact export file the transcript was parsed from, but exports are not signed today, so the file itself could be edited.
- That a transcript came from a particular account. No platform signs its exports, and the account named inside one is text the author can change. A signature and an index show that one identity made these commitments, not that a given account produced the conversation.
- That an author's index is complete. Nothing forces an entry into it. A missing entry is invisible unless someone archived an earlier copy of the index.
- That the visible context was the whole context. Custom instructions, memory, system prompts, project files, uploads missing from the export, and earlier conversations can shape the output without appearing. The tool records what the export contains; it cannot record what the export omits.
- That branches were kept. The layout states whether the export's regenerated and edited branches were included, but an author can edit the export before
init. - That redaction categories are accurate, or that a withheld segment contains anything meaningful. Both are the author's claims until the segment is disclosed.
- That timestamps are real. Timestamps come from the export and are shown as stated times.
- That whoever cites a transcript wrote it. Any artifact can cite any published transcript commitment. Only a signature on a seal or an artifact ties either to a key.
- That the author made only one commitment. Timestamping is cheap and private. See Witnessing.
- That two pseudonyms belong to different people. A pseudonym proves consistency, not uniqueness.
- Who the author is, unless a commitment is signed. See Attribution.
- Anything about who read the transcript. The tool deliberately has no access control and no reader logging.
The honest framing: this is the same guarantee a pre-registered study protocol gives. It stops after-the-fact revision that a reader could detect. It does not stop fraud at the point of authorship, and it does not stop a swap that no reader recorded.
Non-goals
Explicit, because each of these was considered and rejected.
- No access gate. Friction stops the casual reader and the archivist, not the resourced adversary, and gating evidence slows rebuttal when rebuttal speed is the whole point.
- No reader registry, including by accident. A log of who verified whose transcript is a list of journalists and researchers doing legitimate work. It is a subpoena target and a chilling mechanism, and it inverts the power relation the tool is supposed to establish. Earlier revisions avoided building one and then had the verifier fetch the author's index from the author's own server, which produced the same list in the author's web logs: undesigned, unsecured, undisclosed. Verification must not touch anything the author controls. See Nothing learns who is reading.
- No custodian for content, and no single custodian for anything else. No organization holds transcripts, keys, or secrets. The session notary in 0.11 is built so that it cannot: it never holds plaintext or the author's API key. It does hold timing records, and says so in What you are trusting. Any custodian is a breach target, a legal-compulsion target, and a mission-drift risk on a ten-year horizon. This goal survives for everything except one property. Enumerability cannot be self-hosted: an author who hosts the list can rewrite it, an author who snapshots it can rewrite the snapshots, and anyone else who snapshots it is a host in all but name. v1 does not pretend otherwise. It spreads the role instead of filling it: several independent witnesses and archives, none of them load-bearing, plus a history chain that makes a rewritten list contradict commitments readers already hold. An author who will accept no third party at all can still have dates, and gives up enumerability.
- No invisible watermarking or steganographic markers. Fragile against copy-paste and normalization, increasingly flagged as an AI tell, useless against a determined forger, and structurally a leak-tracing mechanism. Artifact binding is done with a plain hash instead.
No hosted service in v1.Reversed in 0.11. Local CLI plus a static verifier page remains the whole of the tool for an author who wants it. The optional session notary is a hosted service, and it is breachable. It is designed so that a breach yields timing records and nothing readable. The reason for the reversal is in Selection.- No private disclosure. A disclosure bundle verifies for anyone who holds it. Showing a withheld passage to one journalist gives everyone they forward it to a verifiable copy. Treat every disclosure as publication.
- No proofs about withheld content beyond its presence. Zero-knowledge statements such as "this passage does not mention X" are out of scope for v1.
- No forced opening. Timelock commitments, which open themselves after a delay, would answer the equivocation problem more forcefully: an author could not sit on unopened variants forever. They are rejected. Passages are withheld to protect third parties and private detail, and a scheme that opens them on a timer breaks that promise. They also still require one known identity per author, so they remove none of the burden below.
- No binary attachments in v1. Attachment text is recorded when the export provides it. File bytes are not.
Risks the author takes on
Committing to a transcript changes the author's position in ways a plain redacted PDF does not. init shows this section and requires acknowledgement.
- Leaks become authentic. Without a commitment, a leaked copy of the unredacted conversation is deniable. With one, anyone who obtains the private session file can prove every withheld passage is genuine, including what third parties said. Stolen laptops, synced backups, and shared machines are the realistic threat.
- Compulsion. The private session file is evidence a court or employer can demand, and destroying it after a demand may be spoliation. The tool removes custodians from the design but makes the author one.
- Scope creep. If the export digest covers an account-wide archive, the private file holds the whole archive, not just one conversation, and every risk above applies to all of it. See Export digest.
- Loss. Losing the private file silently ends the ability to disclose. The published guarantee remains; the "reveal on demand" promise does not.
- Pressure to disclose. Once a commitment exists, declining to reveal a passage reads as concealment. Authors should decide in advance what they will never reveal, and say so in the published notes.
- Tipping off. Witnessing a seal publicly, the moment a conversation ends, announces that something is coming. Signing it under a known identity announces who. See Witnessing and Attribution and the author index. This is a real trade: the unattributed mode is safer and gives up the strongest claim the tool can make.
Mitigations:
- The private file is encrypted at rest with age, using a passphrase or recipient key. The CLI never writes plaintext content to disk except to a path the user names.
- Private files live in a workspace outside any publication directory. See Data model.
forfeitdestroys the session secret. Published passages stay verifiable. Withheld passages become permanently undisclosable, and a leak of the content can no longer be authenticated, because the salts cannot be recomputed. Destruction cannot be proven to anyone else. Forfeit is only available for sessions with an independent secret; see Secrets and salts.withdrawremoves a published passage from the public files. It cannot remove archived copies. See Withdrawal.
Data model
A session is one conversation. A publication is one artifact, with its revisions, plus the sessions it cites. An essay drawing on three conversations is one publication citing three sessions. A session can be cited by more than one publication.
~/.chatterlocks/ # private workspace. Never published.
sessions/<session_id>.age # encrypted: session secret, export bytes, full content
publication/ # everything under here is public
manifest.json # pointers: commitments, stamps, witnesses, signatures
artifact/0.md # artifact source, revision 0, byte-exact
artifact/0.preimage # its SHA-256 is the artifact commitment A_0
artifact/0.preimage.ots
artifact/0.preimage.tsr # optional RFC 3161 timestamp
artifact/0.preimage.sig # optional SSH signature
sessions/<session_id>/
public.json # public leaf table and redaction layout
transcript.public.md # human-readable rendering. Not verified.
seal/1.preimage # optional; its SHA-256 is seal commitment S_1
seal/1.preimage.ots
seal/1.preimage.sig # optional
index.json # the identity's commitment list, as published
index.json.sig # its signature
The CLI refuses to write private data under a publication directory, and publish refuses to run if it finds an .age file anywhere beneath one.
Messages
A message is one turn from the export. Roles:
human,assistant,system,tool.context: custom instructions, memory, or project knowledge, when the export exposes them.attachment: the text of an uploaded file as the export provides it. The filename goes in the meta leaf.
Every message has an optional parent: the index of an earlier message it follows. Regenerated responses and edited prompts are siblings under the same parent.
Messages are ordered by creation time as the export records it, with ties broken by position in the export. A parent is always created before its children, so it always precedes them. The order must stay stable as a conversation grows: a later export of the same conversation must list the earlier messages at the same indices, or a sealed conversation cannot be continued. A parser for a platform whose exports lack creation times must document an order with that property, or refuse --continue.
Units and segments
Content is divided twice.
- Units are fixed at
init, from the bytes alone, before any redaction decision. They are the granularity of the seal. The session'sunit_modeis eithermessage(each message's content is one unit) orparagraph(split at blank lines; see Unit splitting). - Segments are fixed during redaction. Each unit is split into one or more segments that concatenate to it exactly. Each withheld span is exactly one segment.
Every unit boundary is a segment boundary, so a unit can be checked against the seal by disclosing only that unit's segments, not the whole message.
Units and segments are never empty. A message with empty content has no units and no segments.
Leaves
Each message contributes one meta leaf (stated time, stated model, and label: the attachment filename, otherwise empty), followed by one text leaf per segment.
Dispositions:
- Text:
published,hidden, orsubstituted(hidden, plus display text shown in its place). - Meta:
publishedorhidden. The default ishidden.
Canonicalization
Canonicalization happens once, at init. The stored bytes are canonical from then on. Verifiers never normalize. They hash exactly the bytes they are given and reject bytes that are not canonical. Normalizing at verify time is how two correct implementations come to disagree, because Unicode versions and JSON parsers differ.
At init
Applied to every text field (content, model, label):
- Must decode as UTF-8. Exports containing invalid UTF-8 or lone surrogate escapes are rejected.
--replace-invalidsubstitutes U+FFFD and records the replacement count in the layout. - CRLF and lone CR become LF.
- NFC normalization.
- Every code point must be assigned in the Unicode version
initnormalizes with. Text containing an unassigned code point is rejected. That version is recorded in the layout asunicode_version. - Nothing else. No whitespace stripping and no trailing newline added.
The export bytes within the chosen scope are kept unmodified in the private file, for the export digest.
Unit splitting
In paragraph mode, a unit ends immediately after any run of two or more consecutive LF bytes, and at the end of the content. The run belongs to the unit it ends. This is a rule on bytes and does not depend on any Unicode version.
Examples, with ␊ for LF: a␊␊b gives a␊␊ and b. ␊␊␊a␊ gives ␊␊␊ and a␊. a␊b is one unit.
Segment boundaries
Every segment must be valid UTF-8 on its own, so boundaries fall between code points. The tool places them at extended grapheme cluster boundaries (Unicode UAX #29), so that a published segment never ends in a character whose accent or modifier sits in the hidden segment after it.
At verify
- Every segment with published content: valid UTF-8, no CR.
- Every unit whose segments are all available (published, or supplied by disclosure): the concatenation is in NFC. In
paragraphmode it must also be well-formed: it contains no LF LF pair followed by a byte other than LF, and every unit except the last in its message ends with LF LF. A malformed unit fails verification; it means a nonconforming implementation built the session. - Published meta strings: valid UTF-8, no CR, NFC.
- Layout strings (
note,display,parser): valid UTF-8, NFC, no CR.noteandparsercontain no LF. None of them may contain bidi controls, zero-width characters, or other control characters except LF indisplay. These are labels the author wrote, not transcript content, so they are rejected rather than flagged. - Warning, not failure: a published segment that begins with a combining mark is flagged as a display hazard.
NFC is checked on units, not segments, because two segments can each be NFC while their concatenation is not, as with e followed by U+0301.
Units are safe to check separately because of one property of the boundary. LF has combining class zero, and Unicode normalization never reorders characters across a character with combining class zero. So splitting NFC text immediately after an LF leaves both pieces in NFC, and nothing in the second piece can compose or reorder backwards into the first. An outside reviewer confirmed both directions: splitting after an LF keeps each piece in NFC, and pieces that are each in NFC, joined across an LF, are in NFC together, because the LF blocks any composition or reordering between them. The converse is the one the verifier relies on.
Unicode versions. Two correct NFC checkers on different Unicode versions can disagree, but only about code points one of them considers unassigned. A newly assigned character can carry a composition or a combining class that an older implementation, which treats it as unassigned, does not know about. Recent versions have added such characters; Kirat Rai is an example. Unicode's stability policy guarantees the opposite case cannot happen: once a character is assigned, its normalization never changes.
So:
initrejects unassigned code points, which makes every committed transcript consist only of characters assigned inunicode_version.- A verifier whose Unicode data is that version or newer gives the same NFC answer as
initdid. - A verifier whose Unicode data is older reports NORMALIZATION UNCHECKED for each unit containing a code point it does not know, naming both versions. It does not pass or fail those units. Every other check still applies.
Artifact source is hashed as raw file bytes with no normalization.
Field encodings
role: one of the roles above, as ASCII.timestamp:YYYY-MM-DDTHH:MM:SS.sssZ, exactly 24 ASCII bytes, UTC, milliseconds truncated. Empty if the export has none.parent_plus_one: 0 for no parent, otherwise the parent's index plus one. Must be at most the message's own index.category: a code from the category registry, as ASCII.- Integers:
u64(x)is 8-byte big-endian unsigned;u8(x)is one byte. lp(x)=u64(len(x)) || x, length in bytes.
Limits
Verifiers reject anything beyond these limits, so no two verifiers can disagree about whether an oversized session is valid, and no public file can exhaust a reader's browser. Authors who reach a limit split the conversation into sessions.
| Limit | Value |
|---|---|
| Messages per session | 100,000 |
| Leaves per tree | 4,194,304 (2^22), so audit paths have at most 22 hashes |
| Bytes per content field | 8 MiB |
Bytes per meta string, note, display, or parser |
4 KiB |
Bytes per public.json |
512 MiB |
| Seals per session | 256 |
| Entries per index | 1,000,000 |
| Sessions cited per artifact revision | 1,000 |
| Artifact revisions | 10,000 |
| JSON nesting depth | 16 |
Every index and count therefore stays well below 2^32, so JavaScript verifiers can use ordinary numbers.
Category registry
A category has two parts: a kind, saying what was withheld, and a subject, saying whose it is. v0.5 used one code, so a third party's medical detail had to be labelled either health or third-party, and the reader lost one half either way. The rendered marker shows both: [withheld: health · third party].
Kinds:
| Code | Meaning |
|---|---|
name |
A name or pseudonym |
words |
Something a person said or wrote, quoted in the conversation |
employer |
An employer, client, or institution |
location |
A place specific enough to identify someone |
contact |
Email, phone number, address, handle |
identifier |
Account numbers, case numbers, filenames, URLs |
credential |
Passwords, API keys, tokens |
health |
Medical or mental-health detail |
legal |
Legal matters, privileged advice |
financial |
Financial detail |
profanity |
Profanity or slurs |
off-topic |
Material unrelated to the artifact |
other |
Anything else. Requires a note. |
Subjects: 0 self (the author), 1 third-party, 2 unspecified.
note is empty for every kind except other. Codes, not free text, so a verifier can render them in the reader's language and an author cannot put a message in a label. Adding a code requires a new spec version, so that old verifiers never meet a code they cannot render.
Domain tags
Every tag is encoded as lp(tag), so no tag can be read as a prefix of another.
| Tag | Used in |
|---|---|
chatterlocks/v1/session |
Master-mode session secret derivation (HMAC input) |
chatterlocks/v1/salt |
Salt derivation (HMAC input) |
chatterlocks/v1/meta |
Meta leaf data |
chatterlocks/v1/text |
Text leaf data |
chatterlocks/v1/seal-unit |
Seal leaf data |
chatterlocks/v1/export |
Export digest preimage |
chatterlocks/v1/layout |
Layout preimage |
chatterlocks/v1/seal |
Seal commitment preimage |
chatterlocks/v1/transcript |
Transcript commitment preimage |
chatterlocks/v1/artifact |
Artifact commitment preimage |
chatterlocks/v1/history |
Identity history chain |
Every SHA-256 preimage in this spec begins with one of three bytes: 0x00 (RFC 9162 leaf), 0x01 (RFC 9162 node), or 0x02 (everything else, followed by a tag). Leaf data is only ever hashed behind 0x00. The exceptions are SHA256(export bytes) and SHA256(artifact source): hashes of external files, used only as fixed-length fields inside tagged preimages. A new version changes v1 in every tag.
Secrets and salts
session_id = 32 bytes from a CSPRNG. Public.
session_secret = 32 bytes from a CSPRNG. Private. (default)
| HMAC-SHA256(master_secret, lp("chatterlocks/v1/session") || session_id) (master mode)
salt(kind, a, b, c) = HMAC-SHA256(session_secret,
lp("chatterlocks/v1/salt") || u8(kind) || u64(a) || u64(b) || u64(c))
kind |
Leaf | Coordinates (a, b, c) |
|---|---|---|
| 0 | Seal leaf | message, unit, 0 |
| 1 | Meta leaf | message, 0, 0 |
| 2 | Text leaf | message, unit, segment |
| 3 | Export digest | export number, 0, 0 |
Salts exist so a short withheld passage cannot be recovered by hashing candidates: "yes", a phone number, a name from a staff list. HMAC-SHA256 is a PRF, so revealing the salts of disclosed leaves reveals nothing about the others.
The session_id appears in both the salt derivation and every leaf, so a reused secret never makes identical passages in different sessions hash identically.
Master mode gives one secret to back up for every session. The trade:
- Compromise of the master exposes every session's salts, which reopens brute-force recovery of short withheld passages across all of them.
- Sessions in master mode cannot be forfeited individually. Destroying one session's derived secret is meaningless while the master exists.
forfeitrefuses and says so.
Salting protects the hash. It does nothing against inference from surrounding published text. "I work at [withheld: employer · self] headquarters in Cupertino" is not hidden by any amount of entropy.
Commitment construction
SHA-256 throughout.
Leaf data
d_meta(m) = lp("chatterlocks/v1/meta") || session_id || salt(1, m, 0, 0) || u64(m)
|| lp(timestamp) || lp(model) || lp(label)
d_text(m, u, s) = lp("chatterlocks/v1/text") || session_id || salt(2, m, u, s)
|| u64(m) || u64(u) || u64(s)
|| lp(role) || u64(parent_plus_one) || lp(content)
d_seal(m, u) = lp("chatterlocks/v1/seal-unit") || session_id || salt(0, m, u, 0)
|| u64(m) || u64(u)
|| lp(role) || u64(parent_plus_one) || lp(content)
Role and parent are in every text leaf, so a published segment proves its role and its place in the conversation even when meta is withheld.
Trees
Both trees use the Merkle Tree Hash of RFC 9162 §2.1.1:
leaf hash = SHA256(0x00 || d)
node hash = SHA256(0x01 || left || right)
MTH = for n > 1, node hash of MTH(first k leaves) and MTH(remaining n - k),
where k is the largest power of two less than n
This is the same tree as pairing left to right and promoting an unpaired node unchanged. Implementations must follow RFC 9162's definition and inclusion-proof verification algorithm, and must pass the project's test vectors. A tree with no leaves is invalid.
- Transcript tree. For each message in order:
d_meta(m), thend_text(m, u, s)ordered by unit, then segment.n = message_count + total segments. - Seal tree.
d_seal(m, u)for every unit of every message covered by the session's latest seal, ordered by message, then unit. Messages with no units contribute nothing. Each earlier seal commits to a prefix of the same tree. See Seals.
Export digest
Export number j counts the exports a session was built from: 0 for init, then one more for each --continue.
E_j = SHA256(0x02 || lp("chatterlocks/v1/export") || session_id || salt(3, j, 0, 0)
|| SHA256(export bytes within scope))
export_scope says what those bytes are:
- 0
none: no digest. Nothing is kept, and E is omitted wherever it would appear. - 1
conversation: a file, or byte-exact part of a file as the platform delivers it, containing only this conversation. - 2
archive: a file that also contains other conversations.
Some platforms deliver exports only as an account-wide archive. v0.5 digested whatever file it was given, so for those platforms the private file silently held the author's entire history, and revealing the export to a reviewer revealed all of it. init now asks, explains what archive means for leaks and compulsion, and defaults to none when only an archive is available.
E is public and reveals nothing about the export. If a dispute ever calls for it, the author can give a reviewer the export bytes and the salt. The reviewer checks E, re-runs the named parser, and confirms the transcript was derived from those bytes. If platforms someday sign their exports, the same digest lets an old session be tied to a signed file without re-committing.
The encoding u8(scope) || [E, if scope ≠ none] is written export_field below.
Layout
layout = 0x02 || lp("chatterlocks/v1/layout") || session_id || u64(n)
|| lp(parser) || lp(unicode_version) || u8(unit_mode) || u8(branch_policy) || u64(replaced_invalid_count)
|| u64(message_count)
|| for each message:
lp(role) || u64(parent_plus_one) || u8(meta_disposition) || u64(unit_count)
|| for each unit:
u64(segment_count)
|| for each segment:
u8(disposition) || lp(category) || u8(subject) || lp(note) || lp(display)
layout_hash = SHA256(layout)
parser: the name and version of the export parser, for examplechatterlocks-claude-export/1.0.0. ASCII. It also tells the reader which platform or tool the export came from.unicode_version: the Unicode versioninitnormalized with, as ASCII, for example16.0.0. See Unicode versions.disposition: 0 published, 1 hidden, 2 substituted.category,note, anddisplayare empty andsubjectis 2 where not applicable.meta_disposition: 0 published, 1 hidden.branch_policy: 0 the export had no branch information; 1 all branches included; 2 some branches dropped. This is the author's statement.
Every count precedes the list it counts, and every string is length-prefixed, so the encoding is unambiguous. The layout records dispositions as committed; later withdrawals do not change it.
Identity
Anti-equivocation needs one identity, known to readers, with one list of everything it has committed to. The identity is bound into the seal and transcript commitments, so a commitment cannot later be re-homed under a different name.
identity_field = u8(identity_mode) || [lp(identity), if identity_mode != 0]
identity_mode0unattributed: no identity. The commitment proves a date and nothing about how many others exist.identity_mode1sigstore:identityis the email address the author proves control of when signing, for examplemailto:author@example.org.identity_mode2index:identityis the URL of the author's index followed by a space and the SSH key fingerprint that signs it. The identity does not have to be a legal name. A pseudonym with one long-lived key and one index meets the requirement, which is consistency, not disclosure. See Publishing without revealing who you are.
The identity string is public. It is the address at which a reader can enumerate everything this author has committed to. See Attribution and the author index.
History chain
An index published by its author can be rewritten by its author. The chain makes a rewrite contradict commitments that are already in readers' hands.
head_0 = SHA256(0x02 || lp("chatterlocks/v1/history") || identity_field)
head_i = SHA256(0x02 || lp("chatterlocks/v1/history") || head_{i-1} || C_i)
history_field = u8(has_history) || [head_{i-1}, if has_history]
C_i is the i-th commitment made under this identity, in order: a seal or an artifact commitment. Every seal and artifact preimage carries history_field, so each commitment names the exact list of commitments that came before it.
What that buys:
- A reader holding one commitment can ask for the chain behind it. An index that has dropped an entry cannot produce a chain that reaches the head the reader already has.
- An author can still keep two chains and show different readers different histories. That is a fork, and anyone holding commitments from both can see it, because neither head derives from the other.
- It cannot force anyone to publish anything. An author who simply never lists a commitment is invisible until someone archived a copy of the list that contains it.
identity_mode 0 sets has_history to 0. Without an identity there is no chain to be part of.
Seals
A commitment at publication shows the transcript was fixed when the essay went out. It does not show the transcript existed before the essay was written. A seal narrows that gap.
seal_preimage_i = 0x02 || lp("chatterlocks/v1/seal") || session_id || identity_field
|| history_field || export_field(j_i)
|| u8(unit_mode) || u64(sealed_message_count_i) || u64(unit_total_i)
|| MTH(first unit_total_i leaves of the seal tree)
S_i = SHA256(seal_preimage_i)
sealed_message_count_i is the number of messages seal i covers: messages 0 through sealed_message_count_i − 1. unit_total_i is the number of units in those messages. j_i is the export the seal was made from.
seal runs right after init. It needs no redaction decisions, because units come from the bytes alone.
Continuing a conversation. People seal a conversation and then go back to it with one more question. v0.5 had no answer: the seal's unit count no longer matched and verification failed. Now:
init --continue <session> <new-export>re-parses, checks that every message already in the session is byte-identical in the new export (content, role, parent, and meta), and appends the new messages. Any difference aborts.sealcan run again. Each new seal covers more messages than the last, with the sameunit_mode.- Because salts and leaf data depend only on coordinates and content, the first
unit_total_ileaves of the latest seal tree are exactly the leaves sealicommitted to. Every seal is checked against the one public seal tree. - Messages after the latest seal are unsealed, and render as such.
A seal counts only if witnessed promptly, and only says so much even then. An author can seal twenty invented conversations and later use whichever fits. Witnessing proves the chosen one existed by a date; it cannot show it was the only one sealed. That takes an identity and an index. The verifier reports an unwitnessed seal as adding nothing beyond the publication stamp, and an unattributed seal as saying nothing about how many others exist.
Seal leaf hashes are public. For every sealed unit, public.json carries either the seal salt (if every segment of the unit is published) or the seal leaf hash (otherwise). The verifier rebuilds the seal tree from public data and checks every fully published unit against every seal that covers it, with no help from the author.
A partially hidden unit is checked against the seal once all its segments are disclosed. In paragraph mode that means one paragraph. In message mode it means the whole message.
The mode is a trade. paragraph makes seal checks cheaper for the author but publishes a paragraph count for every message, including fully hidden ones. message leaks less and forces larger disclosures. Unsealed sessions use message, since nothing is gained from splitting.
Transcript commitment
T = SHA256(0x02 || lp("chatterlocks/v1/transcript") || session_id || identity_field
|| export_field(latest j)
|| u64(n) || MTH(transcript tree) || layout_hash
|| u64(seal_count) || S_1 || … || S_seal_count)
Seals appear in the order they were made. sealed_message_count strictly increases from one to the next, and all share one unit_mode and one identity_field. An author who changes identity starts a new session.
Artifact commitment
artifact_preimage_r = 0x02 || lp("chatterlocks/v1/artifact") || identity_field || history_field
|| u64(k) || T_1 || … || T_k
|| u64(r) || [A_{r−1}, if r > 0]
|| SHA256(artifact source, revision r)
A_r = SHA256(artifact_preimage_r)
T_1 … T_k are the transcript commitments of every session the revision cites, sorted in ascending byte order with no duplicates, so the same set always encodes the same way. k is at least 1. A later revision may cite more or fewer sessions than an earlier one; the chain shows what changed.
The values that are timestamped, witnessed, and signed are each S_i (at seal time) and each A_r (at publication). T is not stamped on its own; every A_r that cites it covers it.
Artifact binding
The artifact is not a leaf. It must be able to carry every T it cites, which it could not if those depended on it.
Order of operations:
commitcomputes T for each session after redaction.- The author puts every T in the artifact source, for example footer lines
Transcript commitment: <T hex>. publishhashes the final artifact source, computes A_0, and stamps it.
Anyone who archives the essay archives every T.
Hash the source text, not the rendered output. HTML rendering, PDF generation, and CMS processing all mutate bytes. If the artifact is Markdown, hash the Markdown. Publish the source alongside the rendered version, or the binding is unverifiable in practice.
public.json
One per session.
{
"version": "chatterlocks/v1",
"session_id": "…",
"identity": {"mode": "index", "value": "https://example.org/chatterlocks/index.json SHA256:abc…"},
"export": {"scope": "conversation", "digest": "…"},
"parser": "chatterlocks-claude-export/1.0.0",
"n": 8,
"unit_mode": "paragraph",
"branch_policy": "all-included",
"replaced_invalid_count": 0,
"seals": [
{"commitment": "…", "export": {"scope": "conversation", "digest": "…"},
"sealed_message_count": 2, "unit_total": 3}
],
"messages": [
{
"msg": 0, "role": "human", "parent": null,
"meta": {"committed": "hidden", "status": "hidden", "leaf_hash": "…"},
"units": [
{"seal": {"leaf_hash": "…"},
"segments": [
{"committed": "published", "status": "published", "content_b64": "…", "salt": "…"},
{"committed": "hidden", "status": "hidden",
"category": "employer", "subject": "self", "leaf_hash": "…"}
]},
{"seal": {"leaf_hash": "…"},
"segments": [
{"committed": "substituted", "status": "substituted",
"category": "profanity", "subject": "unspecified", "display": "muck", "leaf_hash": "…"}
]}
]
},
{
"msg": 1, "role": "assistant", "parent": 0,
"meta": {"committed": "published", "status": "published",
"timestamp": "2026-09-14T15:04:09.412Z", "model": "…", "label": "", "salt": "…"},
"units": [
{"seal": {"salt": "…"},
"segments": [
{"committed": "published", "status": "published", "content_b64": "…", "salt": "…"}
]}
]
},
{
"msg": 2, "role": "human", "parent": 1,
"meta": {"committed": "hidden", "status": "hidden", "leaf_hash": "…"},
"units": [
{"segments": [
{"committed": "published", "status": "published", "content_b64": "…", "salt": "…"}
]}
]
}
]
}
In this example, message 2 was added with --continue after the only seal, so its unit has no seal field. identity records the mode and string bound into every commitment for this session; mode unattributed carries no value. seals is empty for unsealed sessions. export.digest is absent when scope is none. committed is the disposition in the layout; status is the current state and differs only after a withdrawal. note appears only with category other. Indices and tree positions are not stored; verifiers derive them from the order, so they cannot disagree with it.
Content travels as base64 so no JSON parser can alter it. The verifier renders content only from the decoded bytes.
Strict parsing
For public.json, manifest.json, and disclosure bundles:
- Duplicate object keys are rejected. Parsers disagree on which duplicate wins, which lets one file show different content to different verifiers.
- Unknown fields and unknown versions are rejected.
- Integers are plain JSON integers within the limits: no fraction, exponent, sign, or leading zeros.
- Hex is lowercase and exactly the expected length. Base64 is standard alphabet with padding, and must re-encode to the same string.
- Size and depth limits are enforced before parsing completes, not after.
Disclosure bundles
One bundle format covers every disclosure: one leaf, a whole unit, or a whole message.
{
"version": "chatterlocks/v1",
"session_id": "…",
"commitment": {
"export": {"scope": "conversation", "digest": "…"},
"n": 42,
"mth": "…",
"layout_hash": "…",
"seals": ["…"],
"seal_tree": {"unit_mode": "paragraph", "size": 30, "mth": "…"}
},
"leaves": [
{"tree": "transcript", "kind": "text", "position": 12,
"msg": 4, "unit": 0, "seg": 1, "role": "human", "parent": 3,
"content_b64": "…", "salt": "…", "audit_path": ["…", "…"]},
{"tree": "transcript", "kind": "meta", "position": 10,
"msg": 4, "timestamp": "…", "model": "…", "label": "",
"salt": "…", "audit_path": ["…", "…"]},
{"tree": "seal", "kind": "seal-unit", "position": 9,
"msg": 4, "unit": 0, "role": "human", "parent": 3,
"content_b64": "…", "salt": "…", "audit_path": ["…"]}
]
}
seals lists every S_i in order. seal_tree describes the latest seal's tree, and is null for unsealed sessions.
The verifier:
- Parses strictly and validates every field.
- Computes T from
commitment. - Compares T with a value obtained independently of the bundle: from the artifact, a witness, or the reader's own record. If they differ, verification fails and nothing else is reported as verified.
- For each leaf: recomputes the leaf data and leaf hash, then runs RFC 9162 §2.1.3.2 with index
positionand the tree's size, and checks the result equals that tree'smth. Direction at each level depends on both the index and the size: where a node is unpaired, that level has no sibling. For size 5, position 4, the path is one hash, not three. Paths with too many or too few elements are rejected, as is a position at or beyond the size. - Without
public.jsonand the seal preimages, reports each leaf as committed at its position in T, and stops. It cannot tell which seals cover a seal leaf. - With
public.json, which must itself encode to the samelayout_hash, and the seal preimages, which must hash to the listed seals:- checks each leaf's position is the one computed for its coordinates, and that role, parent, and disposition match;
- for each unit whose segments are all present, checks NFC and well-formedness on the concatenation;
- for each such unit covered by a seal, checks the segments concatenate to the sealed content and that role and parent match, and reports the earliest seal covering it.
A disclosed segment with empty content fails verification. This makes a decoy redaction (a hidden segment with nothing in it, padding the appearance of a redaction) detectable on disclosure.
Proof size is at most ⌈log₂ n⌉ hashes per leaf: nine for a 500-leaf session, under 300 bytes.
Withdrawal
withdraw <msg> <unit> <seg> (or --meta) removes a published leaf's content and salt from public.json, leaving its leaf hash and setting status to withdrawn. If the unit was fully published and sealed, its seal salt is replaced with the seal leaf hash too. The layout does not change, so every commitment still verifies.
This exists for third-party objections and mistakes. It does not unpublish anything: the salts were public, so any archived copy verifies as a disclosure. The verifier reports WITHDRAWN: published at commitment, since removed by the author.
Summary
publish writes summary.json and a rendered summary.html into the publication directory, and upgrade refreshes them. Both are derived entirely from the committed public files. The author writes no part of either. A reader sees the summary without downloading anything or running the verifier.
It states, per session cited by the artifact:
- Platform and parser, unit mode, and identity mode.
- Message count by role, and how many messages are regenerated or edited branches.
- Segments published, withheld, and substituted, with withheld counts broken out by kind and subject.
- Messages fully published, partly withheld, and wholly withheld.
- Seal count, and the stamp and witness date of the earliest seal.
- Stated model and stated times where the author published the meta leaves, and otherwise a line saying they were withheld.
- The transcript commitment, with a copy control.
- Status as recorded at the last
publishorupgrade: whether each timestamp had confirmed, whether any witness is listed, and whether the artifact source contains every commitment it cites.
The proportions are the point. A piece drawing on a session that is nine-tenths withheld says something different from one that is fully published, and a reader learns which before deciding whether to read further. No judgment is rendered on the ratio; it is reported.
Counts are of segments, units and messages, never characters. The length of a withheld passage is not public, deliberately: it narrows guesses at short redactions. A summary that reported withheld characters would undo that.
The status lines are a snapshot, not a check. A timestamp that was pending at publication may have confirmed since; a witness may have rotted. summary.html links to the verifier, which is the live check, and to the full transcript view. Where disclosure is by request rather than published, the summary says so and states what a request produces.
The summary is derived, so the verifier recomputes it. Nothing in the commitments covers summary.json, and it is the one file a casual reader actually looks at, which makes it the obvious thing to doctor. verify regenerates it from public.json and reports any difference as a failure.
A summary alone proves nothing. It is a rendering of the author's own files, and a reader who has not checked the commitment has only the author's word. It exists so that the common case — deciding whether the process behind a piece is worth any attention at all — costs a reader nothing.
Timestamping
Each S_i and each A_r is stamped with OpenTimestamps, which anchors it in Bitcoin, and may carry further timestamps from independent sources. See Redundant timestamps.
- What is stamped. The preimage files. The OpenTimestamps digest of
artifact/r.preimageis SHA256 of its bytes, which is A_r; likewise for each S_i. The verifier checks that the proof's digest equals the commitment and that the commitment equals the SHA-256 of the published preimage. Stamping a text file containing a hex commitment would produce a proof for a different value. - Which attestations count. An OpenTimestamps proof can carry several attestations. The verifier accepts Bitcoin block-header attestations only, reports the earliest confirmed one, and lists any others as ignored.
- Pending proofs. A fresh proof is only a calendar server's promise.
upgradecompletes it once the Bitcoin transaction has at least 6 confirmations. Until then the verifier reports PENDING and names the calendars it is trusting. Calendars can disappear, so authors should upgrade within days. - Block headers. The verifier fetches the attested block header from at least two independent sources, or accepts headers the user supplies, and reports which it used.
- Reorgs. An attestation to a block no longer in the best chain fails, with a message saying so. Requiring 6 confirmations before upgrade makes this unlikely, not impossible.
- Time precision. Bitcoin block timestamps are only loosely constrained and can be off by hours. Report the date, not the minute.
- Ordering. For sealed sessions the verifier reports the interval between each seal and A_0, by stamp and by witness.
- Privacy. Calendar servers see the submitting IP address and the digest, and can link the two once the digest is published. The CLI supports submitting through a proxy such as Tor.
OpenTimestamps supplies dates and nothing else. It cannot show how many commitments an author made, because its aggregation hides exactly that. A transparency log such as Sigstore's Rekor can, but only for a known identity, and at the cost of trusting a log operator. v1 keeps OpenTimestamps for time and puts enumerability in Attribution and the author index, where the author chooses what to trust.
Not a manual process
Nothing in this section is meant to be operated by hand. seal and publish stamp what they create, pending proofs are completed by whatever command runs next, and a failure to reach a timestamp server never blocks the work: the commitment exists either way, and the proof can be added later.
Redundant timestamps
Every timestamp in this design is a proof over the same preimage file. Adding a second kind means adding a second proof file beside the first. Nothing in the commitments changes.
v1 supports two kinds:
- OpenTimestamps (
.ots), anchored in Bitcoin. Nothing to trust but the chain. Slow to confirm. The default. - RFC 3161 timestamp authorities (
.tsr). An established standard: an authority signs the file's SHA-256 together with the time. Free public authorities exist, and they answer in seconds. The cost is trusting that authority's clock and key, which is why one is only ever a second source, never the only one. The token's hash algorithm must be SHA-256 and its message imprint must equal the commitment.
Rules:
- The verifier checks each proof independently, and reports each with what it trusted: the Bitcoin chain and the header sources, or the named authority and its certificate chain.
- Dates are reported per source, never merged into one number. A reader who trusts one source over another can see both.
- A proof that fails is reported as failed. It does not hide the others and does not fail the commitment.
- The privacy rule is the same as for OpenTimestamps: an authority sees the hash and the submitting address, and submission goes through Tor when the author asks.
- A Sigstore signature (
identity_mode1) records a time in Sigstore's public log. The verifier reports it as a further date source when present.
Redundancy protects against one source disappearing, being wrong, or being distrusted by a particular reader. It does not change what a timestamp proves: that the file existed by a date, and nothing about how many others did.
Witnessing
The attack: stamp twenty variant transcripts on day one, with identical published passages and different withheld ones. Publish the commitment for variant 1. When challenged on a withheld passage, replace the files with variant 7, whose stamp is just as old. The public text does not change, so nobody notices unless they recorded the original commitment.
This is a Nostradamus attack: commit to many futures, reveal the one that came true. The term covers several different things in the cryptographic literature; this is the sealed-envelope version, not the hash-herding one. It is cheaper here than in its classic form, because the variants are machine-generated text and producing twenty of them costs almost nothing.
Two different properties are easily confused, and this spec confused them until 0.7:
- A date. A copy of the commitment somewhere the author cannot edit proves the value existed by then. Archives, posts, and the artifact itself give this.
- Enumerability. Knowing that this commitment is one of all the commitments the author made. Only a list tied to a single, publicly known identity gives this.
Witnessing gives the first. No amount of it gives the second; for that see Attribution and the author index. The stamp gives neither on its own.
There is no arrangement that removes other people from this. Enumerability rests on someone other than the author keeping a copy, and the most that design can do is make sure no single copy-keeper matters.
- Every T is printed in the artifact itself, so every archive of the essay is a witness.
publishprints A_r and prompts the author to record it in at least one independent place: an Internet Archive snapshot of the publication page, a post on a separate platform, a mailing list.sealprints S_i with the same prompt. Here it matters more: an unwitnessed seal is worthless.witness add <commitment> <url>records each location in the manifest. The list is author-supplied. It points to evidence; it is not evidence.- The CLI's
verifyfetches each witness and reports the capture date and whose clock it trusted for that date. The static verifier cannot fetch most witness pages, because browsers block cross-origin requests, so it lists each URL with the exact string to look for and asks the reader to check. With no confirmed witness, both report UNWITNESSED in the summary line. - Readers who care should record the commitment themselves. The static verifier has a copy button beside it.
A witness needs a trustworthy date; whether it needs the author's name is a choice. A seal witnessed from the author's own account, the hour a conversation ends, tells anyone watching that the author was working on something and roughly when. For a journalist that can tip off the subject of a story. It also gives away the conversation's end time, which undoes hidden timestamps to within the witness delay. An unattributable witness — an archive capture of a public paste, or a shared log many people post hashes to — avoids that, and gives up enumerability in exchange, because nobody can tell which of those entries are yours. Pick per session, knowing which claim you are keeping.
Selection: the gap a local tool cannot close
Witnessing gives a date. The index gives enumerability, if someone other than the author keeps copies of it. Both see only what the author hands them.
The attack that survives both: start a hundred conversations, keep going until one turns out well, and only then seal it. The ninety-nine others were never committed to anything, so they leave no trail. Nothing in 0.10 can see them, and nothing running on the author's own machine ever could, because the author controls that machine: a local tool can be patched, skipped, or fed a fabricated conversation.
Closing this means committing at the moment a conversation happens, by something the author does not control. That is the session notary.
Session notarization
Optional, per session, and limited in v1 to conversations the author runs through a provider's API with their own key. Sessions in a provider's consumer app cannot be notarized this way, and the badge says so.
Flow.
- The author turns witnessing on for their account. This is a setting held by the notary, not a local flag, so that turning it off is itself a visible event.
- The author's client opens the connection to the provider together with the notary, in the TLSNotary style: the session keys are split between them. The client alone can read the traffic.
- As each turn goes out and comes back, the notary records the time and the number of encrypted bytes. It hashes the encrypted traffic as it streams and never buffers it.
- When the session closes, the notary signs a Session Record and appends it to the account's log.
- The client keeps the plaintext and the data it needs to prove, later, that a given plaintext is what that encrypted traffic contained.
initaccepts the notarized session in place of a platform export, and the session's seal commits to the Session Record.
Session Record.
| Field | Meaning |
|---|---|
spec_version |
Schema version |
account_id |
Pseudonymous account handle |
session_id |
Unique per session |
tier |
notarized, self_attested, or unverifiable; see below |
provider_host |
Host name of the provider, as proven by its certificate |
window_start, window_end |
First and last witnessed contact |
contacts |
One timestamp per turn |
turn_count |
Number of witnessed turns |
transcript_commitment |
Commitment to the encrypted traffic, from which the client can later open the plaintext |
prev_record |
Hash of the account's previous record |
context_manifest |
Self-attested: memory on or off, retrieved files. The notary cannot check it. |
anchor |
OpenTimestamps and Rekor proofs for the log head this record is under |
notary_sig |
Signature over all of the above |
The log holds hashes and times, never content.
Tiers. Every rendering names the tier, every time.
| Tier | When | What it means |
|---|---|---|
notarized |
The notary took part in the session's connection to the provider. | The session existed when it says, and the assistant turns came from the provider's host. |
self_attested |
A local model, or any session the notary did not take part in. | The author's word, labelled as such. Not an accusation. |
unverifiable |
The signature fails, the record is missing from the log, or the chain is broken. | Nothing can be said. |
What the account log shows. With every notarized session in one log under one account, the selection attack leaves a trail even when the author publishes only one session: the others are in the log, unpublished. The index report's cluster rule applies to the log as it does to the index, and the same cautions hold. A short session is not a tell. Many sessions bunched before one publication, or witnessing switched on only for the published session, are patterns for a human to weigh, never a score.
Still not closed. An author can talk to a model somewhere the notary is not. The notary proves the sessions it saw were not chosen after the fact; it says nothing about sessions it never saw.
Attribution and the author index
Anti-equivocation needs two things: an identity readers already associate with the author, and one list of everything that identity has committed to. Without the first, an author makes twenty identities and reveals whichever suits. Without the second, nobody can tell how many commitments exist.
Even with both, equivocation is not prevented. It becomes visible: a trail of commitments made and never opened, sitting beside the piece that cites one of them. Nothing compels an author to open anything. The defense is that the trail is public and the reader can weigh it.
Identity here is the author's, not the platform's. Nothing in an export proves which account produced a conversation.
Holding an identity
sign produces a signature over a preimage file, not over the bare 32-byte commitment. Each preimage begins with its tag, so a signature says unambiguously which kind of commitment it covers, and a signature on a seal cannot be presented as one on an artifact.
- Keyless, tied to an existing account (
identity_mode1). Sigstore signing binds the signature to an email address the author proves control of at signing time, and records the entry in a public log. Enumerability comes for free: anyone can list every entry under that address. The cost is two operators to trust, a certificate authority and a log, which the non-goals otherwise reject, and the permanent loss of anonymity for that identity. - Own key, own index (
identity_mode2). Sign each preimage with a long-lived SSH key (ssh-keygen -Y sign -n chatterlocks-v1 -f <key> artifact/0.preimage), and publish an index listing every commitment the identity has made. No single operator, but not no operator: the index is only as honest as its history is checkable, and you cannot be the one checking it. Use several independent archives, and the history chain, so that dropping an entry contradicts commitments readers already hold. Earlier revisions of this spec claimed this mode had nothing to trust. That was wrong. - Unattributed (
identity_mode0). No signature, no index. Seals and artifacts still prove dates. The verifier states, in those words, that nothing is known about how many other commitments exist.
The default is attributable. The opt-out exists for authors whose safety depends on it. To rotate a key, sign the new key's fingerprint with the old one and record that in the index.
index.json
{
"version": "chatterlocks/v1",
"identity": "https://example.org/chatterlocks/index.json SHA256:abc…",
"entries": [
{"kind": "seal", "commitment": "…", "date": "2026-09-14", "session_id": "…"},
{"kind": "artifact", "commitment": "…", "date": "2026-09-20", "publication": "https://…"}
]
}
A copy of the index, with its signature, ships inside every publication directory. Readers check that copy. They do not fetch it from the author.
The bundled copy proves nothing on its own: it comes from the same person as everything else in the directory, so a pruned index bundles just as happily as an honest one. It exists so that verification needs no network and leaks nothing. The copy that carries weight is an archived one, taken by someone else, and comparing the two is a separate, optional step a reader may take at their own discretion and from their own machine.
Rules, all of which are the author's to keep and the reader's to check:
- Every commitment made under the identity appears, whether or not it was ever published.
- Entries are only appended. Each published version of the index is itself signed and timestamped, so a later version that has lost an entry is detectable against an archived earlier one.
- A commitment whose
identity_fieldnames this index, but which the index does not list, is a contradiction the verifier reports.
The index report
For an attributable publication, verify reads the index bundled with the publication and reports the following as plain numbers, with no adjectives:
- Commitments made under this identity in total, and how many have ever been cited by a publication.
- The date of the earliest commitment in the index, and of the latest.
- Whether this publication's commitments appear in the index, and whether their history chain reaches the index head.
- How many commitments were made within seven days either side of this publication's seals, and how many of those have never been cited.
On top of the numbers the report carries one of three states. The state depends only on the last item, the local cluster. It never depends on totals, on the ratio of cited to uncited commitments, or on how new the identity is:
| State | When | What it means |
|---|---|---|
| Books in order | The identity has a history reaching back beyond this publication, the chain checks out, and no cluster triggered. | Nothing anomalous. Not a statement that the author is honest. |
| New ledger | The index has few entries, or none older than this publication. | A fact, not a doubt. Every honest author starts here, and authors who publish under a pseudonym may stay here by choice. |
| Worth a look | Five or more commitments within a week of this publication's seals, of which all but one were never cited. | The shape a Nostradamus attempt would leave. It is also the shape of a week spent sealing several drafts of the same piece. The report gives the numbers and says both. |
An author with a long history has many uncited commitments, because most conversations never become anything: that is the behaviour sealing is meant to encourage, and it must never count against them. Likewise a new identity is not a suspicious one; it is a new one.
Reader-facing renderings may present this report as Nostradamus's Clerk — the clerk who files the prophet's letters and notices when twenty went out for one race. The name and the illustration belong in the summary page, where writers and readers meet it. This spec and the verifier's text output stay plain.
A signature ties a commitment to a key. It says nothing about whether the key's owner is honest.
Verification
verify must be runnable by someone who has only the public files and no prior relationship to the author. That is the entire point.
Steps, for each session the publication cites:
- Parse
public.jsonstrictly, within limits. - Check structure: every parent is null or less than the message's index; every unit has at least one segment;
nmatches the leaf count;withdrawnappears only wherecommittedispublished;unit_modeismessagewheneversealsis empty; exactly the units of messages below the latest seal'ssealed_message_countcarry a seal field, and a salt only when every segment is published; category and subject codes are in the registry. - Validate and hash every leaf with status
published, including per-unit checks where a unit is fully published. Useleaf_hashfor the rest. - Compute MTH, the layout hash, and T.
- If sealed: rebuild the seal tree from published units and seal leaf hashes. For each seal, hash its preimage file; check the result equals the listed commitment; check the preimage's
unit_modematches, itssealed_message_countis larger than the previous seal's, itsunit_totalequals the unit count of the messages it covers, and its tree root equals the MTH of that prefix of the seal tree.
Then for the publication:
- Parse
manifest.jsonstrictly. It is only a set of pointers; everything in it is recomputed or checked. - Compute each A_r from its artifact source and cited T values, checking the revision chain, and check each source contains every T it cites.
- Verify the OpenTimestamps proofs for every S_i and A_r.
- Check witnesses.
- Check signatures, if present. For an attributable identity, check that every signature verifies against the identity the commitments name, read the bundled
index.json, confirm this publication's commitments appear in it and that their history chain reaches the index head, and report the counts described in Attribution and the author index. The bundled index is the author's own copy; the report says so, every time. - Recompute
summary.jsonfrompublic.jsonand compare it with the published copy. - Render each transcript from verified bytes only, including the branch structure: siblings shown as alternatives, with hidden branches visible as hidden.
Per-leaf status:
VERIFIED: published content matches the commitment.HIDDEN: committed; content not revealed; the category is the author's claim.SUBSTITUTED: committed; the display text is the author's replacement and is not checked against the original.WITHDRAWN: published at commitment, since removed.
Per-unit status: SEALED with the earliest witnessed seal covering it, when the unit's full content is available and matches; UNSEALED for units after the latest seal.
The summary reports: whether the files are consistent with each commitment; stamp status for every S_i and A_r as CONFIRMED (block, date, header sources), PENDING (calendars trusted), or FAILED; witnesses found; signatures; the interval from each seal to publication. It never says "verified" without the stamp and witness status beside it.
Rendering rules
- Display text for substituted segments is never rendered as ordinary transcript text. It is visibly marked and labelled as the author's replacement, so an author cannot use it to put words in a speaker's mouth.
- Hidden segments render as their kind and subject, in the reader's language, and the marker is visually distinct from any text a speaker could have typed.
- Per-unit seal marks appear only for seals that are both confirmed and witnessed. An unwitnessed seal is mentioned once in the summary and nowhere in the transcript, so a column of seal badges cannot lend weight a seal does not have.
- The stated model and stated time are labelled as such.
- Content is HTML-escaped. Bidi controls (U+202A–U+202E, U+2066–U+2069, U+200E, U+200F, U+061C), zero-width characters (U+200B–U+200D, U+2060, U+FEFF), and other control characters are shown as visible markers, since any of them can make displayed text differ from checked text.
False-positive paths this procedure exists to close
- Taking the commitment from the same files it is supposed to check. The verifier shows every T and A_r prominently and says where independent copies were or were not found.
- A valid OpenTimestamps proof for some other digest, or an attestation from something other than Bitcoin.
- Treating PENDING as success.
- Treating an unwitnessed seal as evidence.
- Counting substituted or withdrawn leaves as verified.
- A
public.jsonwhose layout does not encode to thelayout_hashinside a bundle. - A seal preimage whose unit mode, message count, unit count, identity, or tree root disagrees with
public.json. - A commitment naming an index that does not list it, or an index whose signature does not verify.
- A published
summary.jsonthat does not match the one recomputed from the committed files. - Seals out of order, or a later seal covering no more messages than an earlier one.
- A fully published unit whose content does not match its seal leaf.
- Treating the bundled index as independent evidence. It came from the author. Only a copy archived by someone else says anything about completeness, and the report must not blur the two.
- An artifact citing a session whose files were not supplied. The verifier fails rather than skipping it.
- Displaying
transcript.public.mdinstead of verified bytes. - A matching artifact source while the live rendered page says something else. The report says "artifact source matches", never "the essay matches".
Static verifier
A single HTML file that runs offline from disk. It uses the network only to fetch block headers, and only after the user allows it.
The verifier is itself a trust point: the whole design removes custodians, and then asks readers to run the project's code. Three things limit that:
- The static page is built reproducibly, and its SHA-256 is published in the project repository and in each release's signed notes. A verifier page hosted by the author proves nothing.
- The spec is kept small enough that a skeptic can write an independent verifier. The project maintains a reference verifier under 1,000 lines, excluding OpenTimestamps proof parsing, for which it uses the OpenTimestamps reference library. Growth past that budget is treated as a design problem.
- v1 is not released until a second verifier, written independently in a different language, passes the same test vectors.
Nothing learns who is reading
Verifying a transcript must not tell anyone that you are doing it. Not the author, not the project, not a host.
- Verification is offline by default and offline-complete. A downloaded publication directory contains everything needed: public files, preimages, timestamp proofs, signatures, and the author's index.
verifyrun on that directory makes no network connection at all. - The author is never in the verification path. No fetch of the index, no call to a site the author controls. This is the rule the previous revisions broke.
- Optional checks are opt-in, named, and routed. Fetching block headers, archive snapshots, or a transparency-log entry is a separate step the reader asks for. The verifier lists every host first, offers
--via-tor, and accepts block headers from a file or a local node so the check can be made with no third party at all. - The static verifier page carries no analytics, no third-party fonts, and no remote assets. It runs from a local file. A copy served over the web must be served as plain static files.
- Publication hosting should not retain reader identity. Static files, no analytics, no per-reader identifiers. Access logs, if the host keeps them at all, should be short-lived and never presented as part of the evidence.
Abuse, without identifying anyone
The reason to watch readers was never curiosity; it was automated bulk requests. That problem belongs to the host, not to the design, and it can be handled without keeping a list of readers.
- Rate limiting is transient; logging is not. Throttling a burst needs only the request in flight. Keeping who made it afterwards is a separate decision, and the answer is no.
- Counting is allowed; identifying is not. A bucketed count of requests, with no addresses and no per-reader identifiers, tells an author whether anyone verifies. It is operational telemetry, never evidence, and the verifier never reports it.
- Prefer infrastructure that absorbs load instead of inspecting it. A static host or CDN, and an archived mirror of the publication directory, remove the author's server from the path entirely. A reader who verifies from an archive mirror has told the author nothing.
- Escalate in kind. If automated traffic becomes a problem, the response is caching, mirroring, or a static challenge served to everyone alike. Never a per-reader token, a cookie, or an account.
- Say what the host does. A publication that is served from infrastructure with its own logging should say so plainly, so a reader who needs to be unobserved knows to use a mirror or Tor.
Quiet by default
The people this tool most needs to protect are often the ones who least want to be noticed using it. So the tool makes no network contact on its own.
- No telemetry, no crash reporting, no update checks, no downloaded fonts or assets. Not opt-out; absent.
- Only these touch the network: submitting and upgrading timestamps, Sigstore signing, a notarized session (which contacts the provider and the notary), and, in
verify, fetching block headers, witness pages, archived index copies, and notary log entries. Nothing else ever does. - Each of those lists every host it is about to contact and asks before contacting it.
--yesskips the question for scripts; nothing skips the list. --via-torroutes all of them through Tor.--offlinerefuses all of them, and everything that doesn't need a network still works:init,redact,commit,disclose,withdraw,forfeit, sealing without a stamp, and verifying from local files and supplied headers.- A command that stops for lack of a network says which step needed it, so an offline user can carry the files to a connected machine for that step alone.
Getting the tool is the other moment someone can be noticed. Releases are reproducible builds, signed, with their fingerprints published in several places, so a copy from a mirror, a friend, or a USB stick can be checked without ever contacting the project.
Publishing without revealing who you are
Anti-equivocation needs a consistent identity, not a real name. A pseudonym that keeps one key and one index for years builds the same kind of record a named author does, and readers weigh it by its age and history, as they would a long-standing pen name. A pseudonym created yesterday has no record, and the verifier's report makes that visible: an index with one entry says so.
That leaves the practical problem of not giving yourself away by accident. The usual leaks, roughly in order of how often they happen:
- The conversation itself. Your name, employer, town, the people around you. The redaction review exists for this.
- Local-tool exports. Front-ends for local models often embed file paths (
C:\Users\<name>\…), machine names, usernames, and model file locations in exports and system prompts. - Stated times. Timestamps give away your time zone and working hours. Meta leaves are hidden by default; keep them hidden.
- The parser name. It names the software you used. A rare front-end narrows the field.
- Your key. A key you have ever used for GitHub or a server is already linked to you. Pseudonymous use needs a key made for it and used nowhere else.
- Your index's home. A repository shows its owner, and git commits carry the name and email in your git settings unless you change them for that repository.
- The artifact file. Plain text and Markdown carry no hidden author fields. Word documents and PDFs usually do.
- Network traffic. Timestamp servers and index hosts see the address you contact them from. Use
--via-tor. - Timing. Sealing the minute a conversation ends, and publishing on a known schedule, both correlate with you.
publish runs a leak check over everything about to become public and lists what it finds: paths containing a home directory, hostnames, strings matching the local account name, a signing key that is also the default SSH key or git's signing key, git author details in the index repository, document metadata in a non-text artifact, and meta leaves marked published. It lists; it does not fix. A clean result means none of those patterns matched, not that you are anonymous.
What no tool can hide is your writing style. Someone who has read enough of your other work may recognize it.
Verification in ten years
- A publication directory is self-contained apart from Bitcoin block headers. Archivists should mirror the directory, not just link to it.
- Witness URLs rot. Authors should use more than one witness, including at least one archive built for permanence.
- The guarantee lasts as long as SHA-256 collision resistance. The author is the party who would benefit from a collision, so second-preimage resistance is not enough. If SHA-256 weakens, a v2 re-commitment cannot inherit v1's dates; only the original stamps and witnesses can.
- The version tag in every preimage lets a future version change algorithms without any v2 value being mistaken for a v1 value.
CLI surface
Every command that uses the network accepts --via-tor, --offline, and --yes; see Quiet by default. Commands that stamp accept --tsa <url>, repeatable, to add RFC 3161 timestamps alongside OpenTimestamps.
chatterlocks init <transcript-export> [--master <file>] [--unit message|paragraph]
[--export-scope none|conversation|archive]
Show the author risks and require acknowledgement. Parse and canonicalize,
keeping branches. Split units. Generate session_id and the session secret.
Store content, and export bytes if scoped, in the private workspace.
chatterlocks init --continue <session> <new-export>
Re-parse, confirm every existing message is unchanged, append new messages.
chatterlocks seal <session> [--witness archive|none]
Compute the next S_i over all messages so far, write its preimage, stamp it,
append it to the index, sign it, and submit a witness unless told not to.
Print S_i and whatever a human still has to do.
chatterlocks redact <session>
Review every message. Select withheld spans within units, assign kinds and
subjects, set substitutions and meta dispositions.
chatterlocks commit <session> --into <publication-dir>
Build leaves, trees, and layout. Compute T. Write the session's public files.
Print T for inclusion in the artifact.
chatterlocks publish <publication-dir> <artifact-source> --session <id>... [--witness archive|none]
Refuse if any private file is under the directory. Run the leak check and show what it found. Check the artifact contains
every T. Compute the next A_r, write its preimage, stamp it, update manifest.json.
Print A_r and witnessing instructions.
chatterlocks upgrade <publication-dir>
Complete pending proofs once the anchoring transaction has 6 confirmations.
Runs automatically at the start of any later command when proofs are pending,
so it exists for scripts and for forcing the issue.
chatterlocks witness add <commitment> <url>
chatterlocks sign <preimage-file> --key <ssh-key> | --sigstore
chatterlocks index add <commitment> [--publication <url>]
Append a commitment to index.json and re-sign it. Run for every commitment made
under an attributable identity, published or not.
chatterlocks disclose <session> <msg> [<unit> [<seg>] | --meta | --full]
Emit one disclosure bundle. A unit disclosure includes every segment of the
unit and its seal leaf. --full covers meta and every unit of the message.
chatterlocks reveal-export <session> <export-number> --to <dir>
Write the export bytes and export salt, for a reviewer checking E.
States what the bytes contain before writing.
chatterlocks withdraw <session> <msg> (<unit> <seg> | --meta)
Remove a published leaf from the public files, after confirmation.
chatterlocks forfeit <session>
Destroy the session secret, after confirmation. Refuses in master mode.
chatterlocks verify <publication-dir-or-url>
chatterlocks verify-disclosure <bundle> --transcript-commitment <T>
[--public <public.json>] [--seals <dir>]
Redaction rules
Redaction is where users will hurt themselves, so the tool should be opinionated.
- Default to a review pass over every message. Never publish an unreviewed transcript.
- Pattern detection for high-frequency identifiers (emails, phone numbers, street addresses, uploaded filenames, API keys, file paths, usernames, and machine names) prompts human review. It never edits silently.
- Redaction markers carry a kind and subject from the registry, not just a gap. A reader needs to know the shape of what's missing and whose it is.
- Substitutions are listed at the top of the rendered transcript with their categories. If "muck" stands in for something, say so up front.
- Context check. Before
commit, the tool shows each hidden segment with its surrounding published text and asks whether the context gives it away. - Short spans. Hidden segments under a few words get a warning: salting stops hash brute force, not guessing from the sentence around it.
- Third parties. Any segment involving a third party gets a separate warning, whatever its kind. The author can consent to their own exposure; they cannot consent on behalf of someone quoted in the conversation. The tool asks for subject
third-partyrather thanunspecifiedwherever that is true. - Context and attachments.
contextandattachmentmessages are often the most sensitive part of an export and the most important to a skeptical reader. The tool reviews them first and flags them if hidden entirely. - Branches. Keep regenerated and edited branches. Hide the unpublished ones rather than deleting them. Their existence is part of the record, and dropping them sets
branch_policyto 2, which the verifier displays. - Timestamps. Meta is hidden by default. Reply timestamps reveal roughly how long a withheld assistant message was, because generation time tracks output length. Publishing meta around a hidden assistant segment triggers a warning.
- Disclosure planning. In
paragraphmode, the tool shows which paragraphs contain material the author has marked never-disclose, since checking those paragraphs against the seal will never be possible.
Metadata leakage
The public files reveal, by design:
- The number of messages; in
paragraphmode the number of paragraphs in each, including fully hidden ones; and the number of segments in each unit. - The role and parent of every message, and so the shape of the exchange, including how many times a response was regenerated.
- Which positions are withheld, with kind and subject.
- The platform or tool the export came from, through
parser, and whether the digest covers a whole archive. - Timestamps, where meta is published: time zone, working hours, typing time, and the approximate length of withheld replies.
- When each seal was made, and so roughly when the conversation paused and how many times it was resumed. This holds even with meta hidden.
- Over time, which passages the author declines to disclose, and which they withdraw.
- Nothing about readers. Verification is offline, so checking a transcript reveals the reader to nobody, including the author. See Nothing learns who is reading.
- For an attributable identity, the whole commitment history: how often the author works, how much they seal, and how much of it never becomes anything. That is the price of enumerability, and it is why the unattributed mode exists.
Seal leaf hashes and export digests add nothing to this list: both are salted, and the unit count they imply is already public.
Counts could be hidden with padding leaves, but a padding leaf is indistinguishable from a withheld passage, which makes every redaction count meaningless. v1 does not pad, and a disclosed empty segment fails verification.
Future work: reproducible local transcripts
Transcripts from local models are currently the weakest evidence the tool handles, because local front-ends let a user type the model's side directly. They could become the strongest. A local model can be run again. If the export records the fingerprint of the exact model file, the inference software and version, every sampling setting, and the random seed, a reader with the same files can replay the prompts and check that the answers match.
Not in v1, for three reasons:
- Identical output across machines is hard to guarantee. Graphics-card arithmetic is often not repeatable bit for bit, and results can differ with hardware, drivers, and thread counts.
- Few local front-ends record everything needed, and some settings are invisible to the user.
- Checking means downloading the same model, often many gigabytes, which few readers will do.
Nothing in the commitments needs to change to add it later. The stated model field can already carry a model file fingerprint, and the layout records the parser.
Test vectors required before implementation
No implementation should be written against prose alone. The project publishes, before any v1 release:
- MTH and every inclusion proof for trees of size 1 through 17, and 1000, cross-checked against an existing RFC 9162 implementation.
- Leaf data and leaf hashes for each kind, including empty
timestamp,model, andlabel, and a root message with no parent. - Canonicalization: an unassigned code point, which
initmust reject; Kirat Rai text checked by a verifier on an older Unicode version, which must report NORMALIZATION UNCHECKED; CRLF, lone CR, decomposed input, Hangul jamo, multiple combining marks in non-canonical order, U+2028, a leading U+FEFF, invalid UTF-8, and lone surrogate escapes. - Unit splitting and well-formedness:
␊␊␊, leading and trailing blank lines, content that is only newlines, a single trailing LF, and a malformed unit. - A complete publication citing two sessions: one sealed in
paragraphmode, continued once and sealed again, with export scopeconversation; the other sealed inmessagemode with export scopenone. Layouts, E, every S, T, A_0, and A_1, plus disclosure bundles. - RFC 3161 tokens: a valid one, one with a non-SHA-256 imprint, one whose imprint is a different commitment, and one from an authority whose certificate has expired.
- History chain: heads for a five-commitment identity, a chain with one entry removed, and two forked chains from the same head.
- Index report: a cluster that triggers "worth a look", a long history that must not, and a one-entry index that must read as new rather than doubtful.
- Leak check: an export with a home-directory path, a hostname, and the local account name, each of which must be listed.
- A failing vector for every item under "False-positive paths", and for duplicate JSON keys, non-canonical base64, an over-long audit path, an empty disclosed segment, a unit that is not NFC once reassembled, an unknown category or subject code, a bidi control in
display, unsorted T values in an artifact preimage, a continued export that changes an earlier message, and each limit exceeded by one.
Security considerations for review
The last two revisions found no flaws in the core construction. Their changes filled missing use cases, operational gaps, labels, and limits. That is the point at which self-review stops paying and outside review should start. These are the questions that need someone with specific expertise:
- Encoding injectivity (cryptographer). With length-prefixed tags and the 0x00/0x01/0x02 split, is every preimage type unambiguous and distinct from every other? A written proof, not an argument by inspection, would be worth having.
- Salt derivation (cryptographer). Is HMAC-SHA256 keyed by a session secret over (kind, a, b, c) the right construction? Is master mode an acceptable trade?
- Seal prefixes (cryptographer). Several seals commit to prefixes of one tree through RFC 9162 MTH. Is there any way to make a later seal's prefix disagree with an earlier seal while both verify?
- Trees and proofs (implementer with CT experience). Any verifier pitfall in RFC 9162 usage not listed, with two trees per session and several sessions per publication?
- Canonicalization (Unicode specialist). Answered. Reviewers confirmed that per-unit NFC checking is sound in both directions across an LF boundary, and that checkers on different Unicode versions can disagree about newly assigned characters. The
unicode_versionrule in Canonicalization is the response; whether it is complete is still worth a second look. - Message ordering (anyone who has parsed platform exports). Are real exports stable enough under continuation for the ordering rule to hold?
- Witnessing and enumerability (security researcher). Answered: a self-hosted index cannot replace a hosted log, because whoever keeps the snapshots is the host. Still open: how wide should the clustering window be before it is just noise?
- OpenTimestamps (Bitcoin or OpenTimestamps developer). Is 6 confirmations right? Is the time claim stated conservatively enough? Is the attestation handling correct?
- False positives (security researcher). What is missing from the list in Verification?
- Leakage (privacy researcher). What do unit counts, segment counts, branch structure, seal timing, parser identity, and withdrawal patterns reveal beyond the list above?
- Author-side risk (media lawyer, journalist security trainer). Are encryption, the separate workspace, export scope, withdrawal, and forfeit meaningful mitigations? Should the risk section discourage use more strongly? How does a committed transcript fare under subpoena in practice?
- JSON handling (implementer). Any parser differential not closed by strict parsing?
- Reader privacy under load (someone who has run a site under bot traffic). Can bulk automated requests be absorbed with caching, mirroring and transient rate limits alone, with no retained reader identity? What does a static host realistically need to keep?
- Timestamp authorities (PKI or timestamping specialist). Is treating RFC 3161 as a second source, reported separately and never merged, the right way to use it? Which public authorities are dependable enough to suggest?
- History chain (cryptographer). Does chaining each commitment over the identity's previous head close index pruning as far as it can be closed, and is forking the only remaining move?
- The index report's threshold (security researcher, statistician). Five commitments in a fortnight, all but one uncited: is that the right trigger, and what false-positive rate would it have for someone who seals every conversation?
- Pseudonymous use (privacy researcher, journalist security trainer). What is missing from the leak list, and which leaks can the leak check realistically detect?
- Session notary feasibility (someone who has run TLSNotary or a similar MPC-TLS protocol). Long model responses are tens of kilobytes and arrive streamed. Is MPC-TLS practical at that size and latency today, and what does it cost the notary per session?
- Notary trust (security researcher). Is What you are trusting complete? What can a colluding or compromised notary do that it does not list?
- Threat model gap check (anyone). What attack does this design invite that a plain signed PDF would not, beyond those in Risks and Witnessing?
Revision history
0.1, 14 September 2026. Initial draft.
0.2, 15 September 2026. Segment-level redaction;
public.json; layout and leaf count bound into the commitment; artifact as a separate commitment carrying T; witnessing; RFC 9162 tree; normalize-once canonicalization; derived salts; exact stamping rules; author risks, attribution, and metadata sections.0.3, 15 September 2026. Seal over whole messages before redaction, valid only if witnessed; meta leaves;
session_id; master mode; branches andbranch_policy;contextandattachmentroles; withdrawal; chained artifact revisions; derived tree positions.0.4, 15 September 2026. Per-unit NFC; grapheme-boundary segments; paragraph-mode seal; non-empty segments; single bundle format; length-prefixed tag registry; forfeit refused in master mode; rendering rules; long-horizon section; required test vectors.
0.5, 15 September 2026. Private workspace outside the publication directory; public seal leaves, so published units are checked against the seal automatically; multiple sessions per publication; export digest and named parser; category registry; validation of author-written layout strings; signatures over preimage files; manual witness check in the static verifier; verifier independence requirements.
0.6, 15 September 2026. Changes after review of 0.5:
- Continuing a sealed conversation. In v0.5, asking one more question after sealing made the session unverifiable. Seals now commit to growing prefixes of one seal tree, and
init --continuechecks earlier messages are unchanged. - Message ordering specified. v0.5 did not say how parsers order messages, and continuation depends on that order staying stable.
- Export scope. For platforms that only export account-wide archives, v0.5's export digest put the author's entire history in the private file and in any reveal.
- Categories split into kind and subject. v0.5's single code made authors choose between saying what was withheld and saying whose it was.
- Limits added. v0.5 set none, so verifiers could disagree about oversized files, and a hostile
public.jsoncould exhaust a reader's browser. - Witnessing can be unattributable. A seal witnessed from the author's own account can tip off a story's subject and gives away when the conversation ended.
- Seal badges shown only for confirmed, witnessed seals.
- OpenTimestamps attestation handling: Bitcoin only, earliest confirmed.
- "Does not prove" extended: assistant turns typed by the user, and citation as distinct from authorship.
- Review questions now name the expertise each one needs.
- Continuing a sealed conversation. In v0.5, asking one more question after sealing made the session unverifiable. Seals now commit to growing prefixes of one seal tree, and
0.6.1, 18 September 2026. Added the end-to-end walkthrough. No change to the construction, the verification procedure, or any commitment value.
0.7, 20 September 2026. Changes after the first outside review response:
- The multiple-commitment attack is named, and the two properties it involves are separated: a date, which witnessing gives, and enumerability, which it never does.
- Identity bound into seal and transcript commitments, with three modes, and an author index that makes commitments enumerable. 0.6 advised witnessing anonymously for safety while claiming a defense that requires a known identity; those two pieces of advice contradicted each other.
- The verifier reports commitment counts and clustering from the index, without judging them.
- Timelock commitments rejected explicitly, with the reason.
- Computed summary added, recomputed by the verifier so it cannot be doctored.
- "Does not prove" extended: no platform signs exports, so no commitment can show which account a conversation came from; and nothing forces an entry into an index.
0.8, 21 September 2026. Quiet by default: no network contact except for listed actions, each announced first, with Tor and fully offline modes. Redundant timestamps: RFC 3161 authorities as optional second sources beside OpenTimestamps, each reported separately. Guidance and a leak check for authors publishing under a pseudonym, including the point that anti-equivocation needs a consistent identity, not a legal name. Future-work note on reproducible local transcripts. Fixed links broken by the 0.7 section rename.
0.9, 29 September 2026. Changes after the reviewer's second answer:
- History chain: every commitment carries the head of the identity's previous commitments, so an index that drops an entry contradicts commitments readers already hold.
- The no-custodian non-goal rewritten to say what is true: enumerability cannot be self-hosted, and the design spreads that role rather than removing it. The claim that the own-key mode had no operator to trust was wrong and is corrected.
- The index report given three states, driven only by the local cluster pattern. Totals, cited-to-uncited ratios, and the age of an identity never drive it: a long history with many uncited commitments is the expected shape of an honest author, and a new identity is new, not suspect.
- Named, for reader-facing renderings, Nostradamus's Clerk.
- A short version of the flow at the top, and stamping, upgrading, signing, indexing and witnessing folded into the commands that create commitments. The only step left for a person is deciding what to withhold.
0.10, 7 October 2026. Changes after a privacy sweep and a reviewer's answer on normalization:
- Verification is offline by default and offline-complete. The author's index is bundled with the publication and read from there, so checking a transcript no longer reaches a server the author controls. 0.9 kept a "no reader registry" non-goal while having the verifier fetch from the author's own host, which produced that registry in the author's web logs.
- New section on reader privacy, including how to absorb automated bulk traffic with caching, mirroring and transient rate limits rather than by identifying readers.
- The bundled index is marked as the author's own copy wherever it is reported, since only an independently archived copy says anything about completeness.
- The reason paragraph-level NFC checking is sound is now stated: LF has combining class zero, and normalization never reorders across it. The converse the verifier relies on is flagged as not separately confirmed.
0.11, 15 October 2026. Changes after the first reviewer's third answer and two reviewers' answers on Unicode:
- Session notarization. A notary takes part in the encrypted connection to the provider without being able to read it, and signs a record of when the session happened. This closes selection, the attack where unwanted conversations are simply never committed, for the sessions it witnesses. The design considered and rejected a proxy that reads the traffic.
- The "no hosted service" non-goal is reversed, and a new opening section, What you are trusting, says what the notary sees, what it never sees, and what survives it.
unicode_versionin the layout; unassigned code points rejected atinit; older verifiers report NORMALIZATION UNCHECKED instead of guessing. Two correct checkers on different Unicode versions can disagree about newly assigned characters.- The converse NFC property is now confirmed.
Provenance of this document
This specification was drafted in conversation with Claude (Anthropic) on 14 September 2026 and revised with Claude on 15 September 2026, in six passes, each a review of the previous revision, and revised again on 20 September 2026 after the first response from an outside reviewer, who is not named here and has not endorsed anything. Revised on 21 September 2026 for quiet-by-default behavior, redundant timestamps, and pseudonymous authors, on 29 September 2026 after the same reviewer's second answer, on 7 October 2026 after a privacy sweep and a second reviewer's answer on Unicode normalization, and on 15 October 2026 after further answers from three reviewers. No reviewer is named here, and none has endorsed anything. It has not been implemented, tested, or reviewed by a cryptographer. The Merkle and salting constructions follow published patterns (RFC 9162, OpenTimestamps), but the composition here is unreviewed.
Do not ship this without an audit. A provenance tool that gets its own cryptography wrong is worse than no tool, because it hands people confidence they haven't earned.