Write provenance
Every mutating tool call that commits writes one provenance record to cache.db. A record
says which tool ran against which vault, which paths the call named and what their bytes hashed to
before and after, when, and who the server believes called. Records are chained per vault and
signed with the auth registry’s EdDSA key, so a record that is later edited, reordered or removed
fails obsidian-tc provenance verify.
Records hold hashes and attribution only: never note content, never prompts. They are written
by the dispatch pipeline itself (one choke point for every mutating tool, derived from the tool
registry, so a new mutating tool is covered by construction). Settling an already completed write is
fail-open: a fault cannot undo or turn that committed write into an error. The multi-note
commit_wiki_page pending row is deliberately fail-closed, because it is recorded before the
first rename: if that row cannot be stored, the staged files are discarded and the write is refused.
A fault is not silent; see Recording faults.
What is trusted
Section titled “What is trusted”Every attribution field sits in exactly one of three groups, so a reader never has to guess.
| Group | Fields | Trust |
|---|---|---|
verified |
host, server_version, transport; principal and persona only when a bearer token was cryptographically verified (auth.mode: jwt or oidc); session_id |
Established by the server. A client cannot influence them. |
unauthenticated |
principal label seen on stdio or under auth.mode: none |
The server saw a label; nobody proved it. Kept apart from verified so it cannot pass for it. |
self_reported |
MCP clientInfo name and version; the request _meta block io.obsidian-tc/provenance with model, project, agent, machine |
Whatever the client said. A client can lie about its model, project, agent or machine; nothing here is checked. |
A client names itself in the request _meta:
{ "_meta": { "io.obsidian-tc/provenance": { "model": "claude-sonnet-5-5", "project": "notes-sync" } } }Values are strings only and length-bounded; an oversized or non-string value is dropped rather than
truncated. Treat self_reported as a label for humans and audit triage, never as an access
decision.
What a record does not claim:
- Paths are the paths the call named. Side effects the tool performs elsewhere, such as backlink rewrites in other notes, are not listed.
- Digests are a sha256 of the file’s bytes, or
absent(no file there) orunhashable(directory, symlink, unreadable, or over the size cap). A symlink pointing outside the vault is never followed: it recordsunhashable. Beyond 500 paths per call the rest are counted inpaths_omitted, not listed. - The
afterdigest is bound to the write where the tool reports it. Tools that rewrite one note (write_note,append_note,patch_note,update_frontmatter, the tag tools) return the sha256 of the content they wrote, and that value is recorded, not a later read of the disk. Forcommit_wiki_pagesupplies the digest of every file in its batch through the pending-write path, so its settlingokrow uses those same written digests instead of re-reading disk. For every other tool the file is hashed after the handler returns, and the opened file descriptor is checked to be the very file the vault-containment check vetted (same device and inode, with the path re-resolved after the open), so a directory swapped for an outside symlink in that window recordsunhashableinstead of the outside file’s hash. Residual: for those other tools (move, copy, delete, bulk and structured-document tools) a writer that replaces the file inside the vault between the handler returning and the digest being taken would have its bytes recorded as that call’safter. The attribution is still the caller’s; the digest is then “what the file held just after the call”. - Failed and denied calls are not recorded. The exception is a call that threw after a named
path had already changed: it is recorded with
outcome: "error". - A multi-note commit writes a
pendingrecord first.commit_wiki_pagereplaces several notes in one call and cannot be crash-atomic, so just before its first rename it appends a record withoutcome: "pending"listing each named path with the digest it holds now (before) and the digest it is about to write (after). The normalok/errorrecord follows when the call settles (anerrorrecord is then written even if nothing changed). Apendingrecord with nothing after it means the process died mid-commit: compare each path’safterwith the file. If the pending row cannot be appended, the batch fails closed before its first rename.
Signing
Section titled “Signing”A record is signed with the auth registry’s active EdDSA key. The registry exists for
auth.mode: jwt / oidc servers on the HTTP transport; create the key with:
obsidian-tc auth rotate-key --alg EdDSARecords written without such a key are stored unsigned. That is the case for a stdio-only
deployment, which has no registry and is therefore chain-only: the hash chain still catches a
single edited or removed record, but anyone who can write cache.db could rewrite the whole chain
and re-hash it. An HS256 or ES256 active key is never used for provenance (HS256 is a shared secret:
anyone able to verify could forge), so those deployments are also unsigned.
Key rotation does not orphan history: verification accepts every registry key in any state, so a record signed by a since-retired key still verifies for as long as it is kept.
A signed head row per vault pins the last record’s sequence number and hash. Without it a
removed last record would leave a perfectly valid shorter chain; with it, removal is a
head_mismatch.
The head is validated before it is extended
Section titled “The head is validated before it is extended”The head is what makes a deleted prefix or tail detectable, so the server never trusts a head it
finds on disk. Before a new record extends it, and before retention re-anchors it, the head must
pass four checks: it exists whenever records do; it pins the real last record (or its own prune
anchor when none remain); its prune anchor equals the first surviving record’s prev and sequence;
and its signature, if it had one, still verifies under a registry key (any state). Only a head that
passes is re-signed.
If it fails, the server does not re-sign it and does not overwrite it. The write is still
recorded, chained from the real last record and stamped with integrity.head_fault naming the
failed check, so provenance verify reports head_untrusted for it (never hidden by
--allow-unsigned) on top of the bad head itself. The failure is also logged to stderr, counted in
obsidian_tc_provenance_faults_total{kind="head_untrusted"}, and fails the doctor check. Retention
skips a vault whose head fails validation and logs why. The chain then stays failed until the
operator restores cache.db from a trusted backup or archives it: re-baselining it silently would
be exactly the laundering this prevents. A signer outage over a signed head records nothing for
that write (counted as omitted) instead of writing an unsigned head over a signed one.
What a writer of cache.db without the signing key can and cannot do. It can delete or edit
rows, forge pruned_* or head_*, and strip signatures; every one of those fails verification and
is no longer papered over by the next write. It cannot produce a head that verifies, because the
head signature covers every head field. It can still delete everything (all records and the
head row) and restart the chain at seq 1: with no external anchor that is indistinguishable from a
vault that was never written to. It can also hide a stripped, unsigned chain from --allow-unsigned
(see above), and a deployment with no EdDSA key is chain-only and can be rewritten wholesale. If
the auth registry database is lost, an old signed head names a key nobody holds, so the chain reports
head_unknown_kid and new records are stamped head_untrusted until the registry is restored.
Verifying
Section titled “Verifying”obsidian-tc provenance verify # every vault with a chainobsidian-tc provenance verify --vault notes --jsonobsidian-tc provenance verify --allow-unsignedThe command is read-only, opens cache.db and the registry read-only, and exits 1 when any
chain fails. For each vault it prints the record count, how many are signed and unsigned, and every
problem (hash_mismatch, seq_gap, chain_break, head_mismatch, head_untrusted, unknown_kid, bad_signature,
and the head variants). If the auth registry was initialised but <cacheDir>/auth.db is lost, it refuses and
says so: with no keys every signature would read as tampered when the truth is that nothing can be
checked.
--allow-unsigned accepts records that carry no signature. Without it an unsigned record is a
failure, deliberately: if unsigned were acceptable, an attacker could strip every signature and
rewrite the chain. Pass the flag for stdio-only or pre-EdDSA deployments where chain-only integrity
is what you have; a record that has a signature is always checked, flag or not.
obsidian-tc doctor runs the same verification as the provenance chain check: it fails on any
sign of tampering, warns when records exist without signatures, the chain cannot be read, or a
recording fault is on file, and notes (without warning) a fresh install that has no EdDSA key yet.
Querying one note’s history
Section titled “Querying one note’s history”get_provenance answers “who changed this note, and when” for a single path:
{ "vault": "notes", "path": "projects/plan.md", "limit": 20, "include_verification": true }Records come back newest first (by sequence number). limit is 1 to 200 (default 50); when more
remain, next_cursor is an opaque, signed cursor to pass as cursor for the next page of the same
request. It is bound to the caller, the tool and the exact arguments and it expires, like the other
paged reads; a cursor the server did not issue for this request (a made-up number, another request’s
cursor) is an invalid_input error. since and
until are epoch milliseconds, inclusive. Each record has the tool, outcome, ts, the sha256
before and after of the queried path, seq, the record hash, every readable path the call
named (paths_truncated when the call named more than the record lists), and the attribution in the same three groups as the stored record. They are returned
separately and never merged: verified is what the server established, unauthenticated is a
label the server saw and nobody proved, and self_reported is what the client said about itself
(model, project, agent, machine, client) and can be false. Decide nothing from
self_reported. response_format: "concise" keeps the grouped attribution (without host,
server_version, transport and machine) and drops the full path list and the record hash.
Who may call it. The tool requires read:provenance in addition to read:notes. A record
names the principal and session of whoever wrote, which is audit data and not part of being allowed
to read a note, so it is its own grant (read:* and * include it, as for any scope). Anyone who
holds it sees the principal, persona and session of other principals for the paths they can read;
there is no finer per-principal gate.
Access control. The query uses the same read check as search and the link tools, against the ACL of the vault being queried:
- A path the caller cannot read answers with the same
not_founderror, with the same message and details, as a path that was never written. The tool declares no central path check on purpose: that would answeracl_denied, which says the note exists. Withsince,untilorcursor, both give an empty page. - A record that names several paths (a move, a copy, a bulk call) lists only the paths the caller
can read. A path they cannot read is left out with no placeholder and no count, and a record
keeps only its readable entries. Nothing derived from a left-out path is returned either: when a
record had a path left out, its
hashis a hash of the view the caller can see, not the stored record hash (which covers the hidden path, so it would let a caller test a guess at it), and it never carriespaths_truncated. A record with nothing left out returns its stored hash. - A vault’s records and its ACL never serve another vault.
Moves. A record lists a move as a [from, to] pair. When the newest move_note,
bulk_move_notes or move_attachment record moved a file onto the queried path, the history of
the source path is part of the result (previous_paths, and each record’s path says which path it
matched), and the walk repeats for that source, up to 32 moves. The walk stops at a source the
caller cannot read, so it never learns the source existed. A move record is followed only if it
verifies (the same per-record check as include_verification, run whether or not it was
asked for). A move that fails it (an edited body, a bad or missing signature, an indexed column that
disagrees, or no key registry to check a signed record with) is not followed: the result carries
lineage_incomplete with the reason (a verify problem code, unverifiable, or max_hops) and the
seq of that record, and neither the earlier paths nor their records are returned. Without a key
registry (stdio-only) unsigned records are the norm and are followed. Records from before the file arrived at a
path (a previous occupant it overwrote) are not part of its history. The pass stops once a page is full, so previous_paths lists the earlier paths reached up to the
last record returned; a later page reaches further back. Moves are not followed
forwards: the old path shows its history up to the move, not what became of the file. Renames done by
other means (a tool that only deletes and creates) are plain writes, so they are not linked.
Verification. With include_verification, each record carries verification:
signature is valid only when the signature verifies and the body still hashes to the signed hash and agrees with its indexed columns; a record whose content was edited is invalid (a chain break is chain_link, and does not change signature). Otherwise it is unknown_key, unsigned, or unverifiable (no key registry,
for instance a stdio-only deployment, or an unreadable one: signatures cannot be checked, which is
not evidence of tampering); chain_link is ok or broken; problems lists the codes
provenance verify uses; ok is true only for a valid signature with no problem. This reuses the
verifier’s own per-record check, so the two cannot disagree about one record. It does not prove
the chain is complete: a removed later record or a forged head is only visible to
obsidian-tc provenance verify. The query reads cache.db, as verify does, so a writer of that
file can forge records but not signatures (see the limits above).
Limits. A record stores the paths the call named, up to 500 per call (see paths_omitted): a
note beyond that cap in a bulk call has no record that names it. The records have no path index (a
stored path is the spelling the call named, which only normalizes in code, so an index column would
need a backfill SQL cannot do), so a query is one newest-first pass over the vault’s chain with a
text prefilter on the file name, following moves in the same pass. The pass examines at most
provenance.query.maxScanRows rows (default 100000; about half a second at the worst measured, on a
loaded 4-core host, for a 100000-row chain with no match). A longer chain is read newest-first and
the result carries scan_truncated: true: older history was not examined. A path whose only history
lies beyond the budget answers not_found, like a path with none (an unreadable path must look the
same). Raise the budget for a long-retention vault, or bound the chain with retentionDays; provenance verify
is not bounded by it. Each page restarts the pass from the newest record, so a deep page costs what the
first one does.
Recording faults
Section titled “Recording faults”Because settlement is fail-open after a write has committed, a write whose final record could not be stored (a dropped
table, a sequence collision, a signer outage over a signed head) would otherwise look like “no
write happened”, and the chain would still verify. Each such fault is therefore made visible three
ways: a [provenance] line on stderr, the obsidian_tc_provenance_faults_total counter (labels
vault, tool, kind of omitted or head_untrusted), and a provenance_fault row in
event_log, which is what obsidian-tc doctor (a separate process) reads to warn “N committed
writes left no record”. The doctor warning ages out with event_log retention; the counter resets
with the process. The pending-row exception fails before any write, as described above.
Optional stamps
Section titled “Optional stamps”The record in cache.db is the source of truth. Two optional stamps copy a little of it to
where a human will see it. Both are off by default; with them off, every note and every commit
message is byte for byte what the caller sent. Neither stamp holds anything the record does not
already hold, and neither ever contains the host id. Both need provenance.enabled.
Commit trailers
Section titled “Commit trailers”provenance.stamp.gitTrailers: true makes the git_commit tool append trailers to the message it
sends to the Git bridge:
snapshot
Obsidian-TC-Session: 7f3c0eObsidian-TC-Principal: aliceObsidian-TC-Model: claude-sonnet-5-5 (self-reported)Obsidian-TC-Provenance-Seq: notes:41-44- Trust is in the text.
Obsidian-TC-Principalis the principal a bearer token proved (auth.mode: jwtoroidc), or the wordunverified(stdio,auth.mode: none). The model is a client’s own claim and is always followed by(self-reported). A value is one line: control characters, including a newline in a claimed model, are replaced by a space, so a client cannot end its own trailer and start another. - Which writes. The server asks the bridge what is staged, then keeps each staged note whose
bytes right now equal what a record says that write left (
after). A note a human edited since is not attributed to the agent, a deletion is not attributed at all, and a path no record names is ignored. A repo that sits above the vault (Vault/a.mdin Git,a.mdin the vault) still matches. Only the newest 2000 records of the vault are searched. A commit that includes no recorded write gets no trailers, and so does a commit whose staged list could not be read: the commit itself is never failed or delayed by stamping. Obsidian-TC-Provenance-Seq: <vault>:<from>-<to>bounds the matching records. Other records can sit between the two numbers (a write to a note that is not in this commit); the range says where to look, the digests say which.- One value per writer. If the commit holds writes from several sessions, principals or models,
each distinct value gets its own trailer line (at most 10 of a kind; more adds
Obsidian-TC-Truncated). - Your message is kept. Trailers go at the end, after a blank line, or into the message’s
existing trailer block (
Signed-off-by: ...) the waygit interpret-trailerswould. The one exception: anObsidian-TC-*trailer you wrote in the final trailer block is removed when this stamp is on, so that a trailer under the prefix is always the server’s. The server does not rungiton the vault to do any of this. - The tool result gains
stamped_trailers(the lines that were added) when there were any.
What a trailer does not prove. Trailers live in the commit message, which anyone who can rewrite history can change; the signed chain is what proves the record. The session id is the server’s own, but the model is only what the client said.
Frontmatter stamp
Section titled “Frontmatter stamp”provenance.stamp.frontmatter: true writes one key into a note an agent creates:
obsidian_tc_provenance: session: 7f3c0e principal: alice model_self_reported: claude-sonnet-5-5 seq: 41- Creations only:
write_notewhen the note did not exist (create, orupsertof a new note),commit_capture,commit_wiki_pagefor the new page it creates (never for an overwrite or a patched note; the page’s frontmatter is checked against the wiki’sSCHEMA.mdwith the stamp in it, so a property vocabulary that does not declare the stamp key gets aschemaproblem), andexecute_templatewhen the target did not exist before the call.write_noteover an existing note,append_note(includingcreate_if_missing),patch_note,update_frontmatter,execute_templatewithoverwriteand every other tool never stamp. - Human frontmatter is never modified. The key is added with the vault’s own line-preserving frontmatter writer, so every other key, comment and scalar keeps its exact source bytes. A note whose frontmatter is not valid YAML is written unstamped. The one thing replaced is a value the caller put under the stamp key in a note it is creating, so a forged stamp cannot pass as the server’s.
- Fields:
session(when the call had one),principal(verified, elseunverified),model_self_reported(when the client claimed one) andseq.seqis where the write’s own record is expected to land: one past the newest record of the vault when the note was written. It is exact when writes to a vault do not overlap and never above the real number otherwise; the authoritative link is the record whose path andafterdigest match the note. - Verification is unaffected. The stamp is part of the bytes written, so the recorded
afterdigest is the digest of the stamped note, andprovenance verifypasses exactly as before. - A stamp is a label, not proof. Anyone who can edit the file can edit the stamp. Check a stamped note against the chain, not the other way round.
execute_templatestamps after Templater has written the note, so the note exists unstamped for an instant and a stamp failure leaves Templater’s output as it was.
Configuration
Section titled “Configuration”{ "provenance": { "enabled": true, "host": { "mode": "hashed" }, "retentionDays": 365, "query": { "maxScanRows": 100000 }, "stamp": { "gitTrailers": false, "frontmatter": false, "frontmatterKey": "obsidian_tc_provenance" } }}enableddefaults totrue;falserecords nothing.host.modeis"hashed"(default, a stable digest of the machine’s hostname) or"label", which recordshost.labelverbatim and requires it.retentionDaysis absent by default, which keeps records forever.query.maxScanRows(1000 to 10000000, default 100000) is the most rows oneget_provenancecall examines.stamp.gitTrailersandstamp.frontmatterdefault tofalse;stamp.frontmatterKeynames the key (letters, digits,_and-, up to 64 characters). Setting either stamp whileenabledisfalseis a configuration error.
A hashed host id lets records correlate across restarts without naming the machine.
Renaming a vault
Section titled “Renaming a vault”The vault id is part of every signed record, so a vault rename does not re-key provenance: the old
chain stays under the old id and still verifies (provenance verify --vault <old-id>), and new
records start a new chain under the new id.
Retention
Section titled “Retention”provenance.retentionDays is unset by default: this is an audit trail and pruning it is an
explicit decision. When set, the maintenance sweep removes each vault’s records older than the
window as one contiguous prefix and moves a signed prune anchor (in the head row) up to the last
record dropped. The surviving chain therefore still verifies. Removing any record that is not part
of the oldest prefix, or dropping a prefix without a matching signed anchor, still fails
verification. Records are chosen for deletion by their ts column, which is not itself signed, so
before deleting anything the sweep verifies every record it is about to drop (its hash, its columns
against the signed body, its link and sequence, its signature). A chain that fails this (an edited
ts, a rewritten body) is left exactly as found, no anchor is signed, and the sweep logs the failing
record; provenance verify keeps reporting the original problem instead of a clean, shorter chain.
A sweep that has no signer available leaves a signed chain alone rather than
downgrading its head to unsigned. The sweep reports the count as provenance.