Skip to main content

solid_pod_rs/
provenance.rs

1//! Provenance primitives — composable, cost-tiered traceability for pod writes.
2//!
3//! Implements the data model and traits from
4//! [`docs/design/provenance-upgrade-master-plan.md`](../../docs/design/provenance-upgrade-master-plan.md)
5//! §2 and [ADR-059](../../docs/adr/ADR-059-provenance-primitives-block-trails-git-marks.md)
6//! (D1, D2, D4, D6). Two tiers compose into one chain:
7//!
8//! - **git-mark** (cheap, always-on): every pod write becomes a git commit;
9//!   the commit SHA is captured as a [`GitMark`]. Content-addressed,
10//!   append-only, tamper-evident ordering for free. The native implementation
11//!   of [`GitMarker`] lives in `solid-pod-rs-git::mark` (it shells to `git`);
12//!   wasm consumers compile against a no-op marker.
13//! - **block-trail anchor** (expensive, opt-in): a Bitcoin-anchored MRC20 state
14//!   whose taproot UTXO externally timestamps a record ([`BlockTrailAnchor`]).
15//!   Reserved for high-value records. The [`BlockAnchorer`] trait is defined
16//!   here; a real implementation lands in Phase 4 (`bitcoin_tx.rs` + mempool).
17//!
18//! A [`ProvenanceMark`] always carries a [`GitMark`] and *optionally* a
19//! [`BlockTrailAnchor`]. The anchor's `state_hash` commits to the git SHA (or an
20//! epoch Merkle root over many commits), binding both tiers into one chain.
21//!
22//! ## wasm32 safety
23//!
24//! Everything in this module — the types and [`prov_ttl`] — is pure logic and
25//! compiles for `wasm32-unknown-unknown`. The traits are `?Send` (matching the
26//! crate's existing [`crate::payments::PaymentStore`] pattern) so a wasm
27//! single-threaded executor can implement them. No `tokio`, no process spawning,
28//! no I/O leaks into this surface.
29
30use std::path::Path;
31use std::sync::Arc;
32
33use serde::{Deserialize, Serialize};
34use sha2::{Digest, Sha256};
35
36// ---------------------------------------------------------------------------
37// Data model (§2.1)
38// ---------------------------------------------------------------------------
39
40/// A provenance mark over a pod resource write.
41///
42/// Always carries a git commit ([`GitMark`]); optionally upgraded with a
43/// Bitcoin block-trail anchor ([`BlockTrailAnchor`]) for high-value records.
44#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
45pub struct ProvenanceMark {
46    /// Pod-relative path of the resource the write targeted.
47    pub resource: String,
48    /// The git commit the write produced — **always present** (cheap tier).
49    pub git: GitMark,
50    /// Optional Bitcoin block-trail anchor — **opt-in** (expensive tier).
51    #[serde(default, skip_serializing_if = "Option::is_none")]
52    pub anchor: Option<BlockTrailAnchor>,
53    /// `did:nostr` of the writer (NIP-98 authenticated principal), or an
54    /// anonymous marker when the write was unauthenticated.
55    pub agent_did: String,
56    /// Unix seconds at which the mark was produced.
57    pub created: u64,
58}
59
60/// The cheap-tier git commit captured for a pod write.
61#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
62pub struct GitMark {
63    /// Git SHA-1 of the commit the write produced.
64    pub commit_sha: String,
65    /// Pod repo slug (the pod's first path segment / pubkey).
66    pub repo: String,
67    /// Branch the commit landed on. Pinned to `"main"` by `init.rs`.
68    pub branch: String,
69    /// Prior commit SHA (the append-only chain link), or `None` for the
70    /// genesis commit of a freshly-initialised repo.
71    #[serde(default, skip_serializing_if = "Option::is_none")]
72    pub parent: Option<String>,
73}
74
75/// The on-disk `gitmark.json` envelope — the Carvalho-lineage substrate
76/// shape (ADR-124, C7), verified byte-for-byte against
77/// `microfed/gitmark.json`.
78///
79/// **Exactly five keys**: `@id`, `genesis`, `nick`, `package`, `repository`.
80/// `@context`/`@type`/`commit`/`parent` are deliberately ABSENT — they are
81/// not in the ground-truth file (parent-linkage lives in `blocktrails.json`
82/// `states[]`/`txo[]`, not here). For byte-parity with create-agent, emit
83/// only these five keys.
84///
85/// `@id` is `gitmark:<commit_sha>:<vout>`; `genesis` is
86/// `gitmark:<first-commit-sha>:0` (and equals `@id` for the first mark).
87/// This is a *projection* of the internal [`GitMark`] — the in-memory
88/// `{commit_sha, repo, branch, parent}` shape (used by the PROV-O sidecar +
89/// the composition log) is unchanged; only the on-disk substrate file
90/// adopts this shape.
91#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
92pub struct GitMarkEnvelope {
93    /// `gitmark:<commit_sha>:<vout>` — the single-use-seal coordinate.
94    #[serde(rename = "@id")]
95    pub id: String,
96    /// `gitmark:<first-commit-sha>:0` — the trail's genesis seal.
97    pub genesis: String,
98    /// Short human name for the mark (e.g. the package/pod nickname).
99    pub nick: String,
100    /// Pod-relative package path (e.g. `./package.json`).
101    pub package: String,
102    /// Repo-relative root (e.g. `./`).
103    pub repository: String,
104}
105
106impl GitMark {
107    /// Project this internal [`GitMark`] onto the canonical 5-key
108    /// `gitmark.json` envelope (ADR-124, C7).
109    ///
110    /// - `vout` is the seal output index for this mark's `@id`
111    ///   (`gitmark:<commit_sha>:<vout>`).
112    /// - `genesis_sha` is the trail's first commit SHA; pass this mark's own
113    ///   `commit_sha` for the genesis mark (then `genesis == @id`).
114    /// - `nick`/`package` are the additive projection fields; `repository`
115    ///   defaults to `./` (repo-relative root, matching the ground truth).
116    ///
117    /// `branch`/`parent` are intentionally NOT emitted — they are not part of
118    /// the on-disk envelope. Parent-linkage is carried by `blocktrails.json`.
119    #[must_use]
120    pub fn to_gitmark_envelope(
121        &self,
122        vout: u32,
123        genesis_sha: &str,
124        nick: impl Into<String>,
125        package: impl Into<String>,
126    ) -> GitMarkEnvelope {
127        GitMarkEnvelope {
128            id: format!("gitmark:{}:{vout}", self.commit_sha),
129            genesis: format!("gitmark:{genesis_sha}:0"),
130            nick: nick.into(),
131            package: package.into(),
132            repository: "./".to_string(),
133        }
134    }
135
136    /// Serialise this mark to the canonical `gitmark.json` text (5-key
137    /// envelope, ADR-124). Convenience over [`Self::to_gitmark_envelope`] +
138    /// `serde_json::to_string_pretty`.
139    pub fn to_gitmark_json(
140        &self,
141        vout: u32,
142        genesis_sha: &str,
143        nick: impl Into<String>,
144        package: impl Into<String>,
145    ) -> Result<String, ProvenanceError> {
146        let env = self.to_gitmark_envelope(vout, genesis_sha, nick, package);
147        serde_json::to_string_pretty(&env)
148            .map_err(|e| ProvenanceError::Store(format!("gitmark.json serialise: {e}")))
149    }
150}
151
152/// The expensive-tier Bitcoin anchor for a record.
153///
154/// Reuses the existing [`crate::mrc20`] crypto (`Mrc20State`, `bt_address`,
155/// `verify_mrc20_anchor`) — no crypto is re-implemented here. The
156/// `state_strings` carry the portable, independently-verifiable proof.
157#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
158pub struct BlockTrailAnchor {
159    /// Trail ticker / identifier.
160    pub ticker: String,
161    /// `sha256_hex(jcs(state))` — links into the MRC20 trail and commits to
162    /// the git SHA (or an epoch Merkle root).
163    pub state_hash: String,
164    /// Bitcoin transaction id of the anchoring UTXO.
165    pub txid: String,
166    /// Output index of the anchoring UTXO.
167    pub vout: u32,
168    /// Derived P2TR address (`mrc20::bt_address`).
169    pub address: String,
170    /// `"testnet4"` | `"mainnet"` (or any network the operator configures).
171    pub network: String,
172    /// Confirmation height; `None` until the anchoring tx confirms.
173    #[serde(default, skip_serializing_if = "Option::is_none")]
174    pub blockheight: Option<u64>,
175    /// Portable, independently-verifiable proof — the serialised states.
176    #[serde(default)]
177    pub state_strings: Vec<String>,
178    /// Issuer's compressed pubkey (66-char hex). Together with
179    /// `state_strings` it re-derives the taproot `address` via
180    /// `mrc20::bt_address` — the read-side check
181    /// ([`BlockAnchorer::verify`])
182    /// needs it to confirm `address` was not forged. `None` on legacy /
183    /// partially-populated anchors (verify then has nothing to re-derive
184    /// against and reports `false`).
185    #[serde(default, skip_serializing_if = "Option::is_none")]
186    pub pubkey: Option<String>,
187}
188
189/// A single UTXO step in a [`BlocktrailEnvelope`] `txo[]` chain — the
190/// BIP-341 single-use-seal coordinate (`<txid>:<vout>`) plus its
191/// confirmation status.
192#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
193pub struct BlocktrailTxo {
194    /// `<txid>:<vout>` — the seal output this state was sealed under.
195    pub outpoint: String,
196    /// Confirmation height; `None` until the sealing tx confirms.
197    #[serde(default, skip_serializing_if = "Option::is_none")]
198    pub blockheight: Option<u64>,
199}
200
201/// The on-disk `blocktrails.json` envelope — the 4th web-contract layer
202/// (ADR-124 §2.2). The `gitmark.json` substrate anchors the reducer/state/
203/// ledger layers; `blocktrails.json` is the **trail** layer.
204///
205/// Per the reconciliation (C6): this shape is reconstructed from the
206/// `webcontracts.org` / "Melvo Predicts" reference pattern — it is NOT
207/// byte-verifiable against a fetchable create-agent artefact (the
208/// create-agent repo contains only `gitmark.json`). Only `gitmark.json` is
209/// "verbatim".
210///
211/// Shape: `@type "Blocktrail"`, `profile "gitmark"` (the anchoring
212/// substrate), a BIP-341 single-use-seal chain expressed as `states[]`
213/// (the commit SHAs — the ledger states, in order) paired with `txo[]` (the
214/// UTXO chain that seals them). `genesis` ties back to the `gitmark.json`
215/// genesis seal.
216#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
217pub struct BlocktrailEnvelope {
218    /// `gitmark:<first-commit-sha>:0` — the genesis seal (shared with the
219    /// `gitmark.json` `genesis`).
220    #[serde(rename = "@id")]
221    pub id: String,
222    /// Always `"Blocktrail"`.
223    #[serde(rename = "@type")]
224    pub type_: String,
225    /// Anchoring substrate profile — always `"gitmark"` here.
226    pub profile: String,
227    /// `gitmark:<first-commit-sha>:0`.
228    pub genesis: String,
229    /// The ledger states in order — commit SHAs the trail notarises.
230    pub states: Vec<String>,
231    /// The BIP-341 single-use-seal UTXO chain sealing `states[]` (1:1 order).
232    pub txo: Vec<BlocktrailTxo>,
233}
234
235impl BlocktrailEnvelope {
236    /// Build a `Blocktrail` over an ordered set of commit SHAs (`states`)
237    /// and their sealing UTXO chain (`txo`), profiled on the `gitmark`
238    /// substrate (ADR-124 §2.2, C6 reference shape).
239    ///
240    /// `genesis_sha` is the trail's first commit SHA; the envelope's `@id`
241    /// and `genesis` are both `gitmark:<genesis_sha>:0`.
242    #[must_use]
243    pub fn new_gitmark_profile(
244        genesis_sha: &str,
245        states: Vec<String>,
246        txo: Vec<BlocktrailTxo>,
247    ) -> Self {
248        let genesis = format!("gitmark:{genesis_sha}:0");
249        Self {
250            id: genesis.clone(),
251            type_: "Blocktrail".to_string(),
252            profile: "gitmark".to_string(),
253            genesis,
254            states,
255            txo,
256        }
257    }
258
259    /// Serialise to the canonical `blocktrails.json` text.
260    pub fn to_blocktrails_json(&self) -> Result<String, ProvenanceError> {
261        serde_json::to_string_pretty(self)
262            .map_err(|e| ProvenanceError::Store(format!("blocktrails.json serialise: {e}")))
263    }
264}
265
266// ---------------------------------------------------------------------------
267// Errors
268// ---------------------------------------------------------------------------
269
270/// Failures surfaced by the provenance primitives.
271///
272/// Hand-rolled (no `thiserror` derive) so the type compiles on `wasm32`
273/// without pulling proc-macro evaluation into the pure surface; the variants
274/// mirror the crate's error-message style.
275#[derive(Debug, Clone, PartialEq, Eq)]
276pub enum ProvenanceError {
277    /// The underlying git operation failed (spawn, commit, rev-parse, …).
278    Git(String),
279    /// The Bitcoin anchor operation failed (mempool, tx-build, verify, …).
280    Anchor(String),
281    /// The resource path was rejected (traversal, sidecar suffix, …).
282    InvalidPath(String),
283    /// Persisting or emitting the mark failed.
284    Store(String),
285}
286
287impl std::fmt::Display for ProvenanceError {
288    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
289        match self {
290            ProvenanceError::Git(m) => write!(f, "git-mark: {m}"),
291            ProvenanceError::Anchor(m) => write!(f, "block-anchor: {m}"),
292            ProvenanceError::InvalidPath(m) => write!(f, "invalid provenance path: {m}"),
293            ProvenanceError::Store(m) => write!(f, "provenance store: {m}"),
294        }
295    }
296}
297
298impl std::error::Error for ProvenanceError {}
299
300// ---------------------------------------------------------------------------
301// Traits (§2.2)
302// ---------------------------------------------------------------------------
303
304/// Cheap tier. Implemented by `solid-pod-rs-git` (shells to `git`).
305///
306/// `?Send` for wasm32-safety, matching the crate's [`crate::payments::PaymentStore`]
307/// pattern. The wasm `core` consumer compiles against a no-op marker.
308#[async_trait::async_trait(?Send)]
309pub trait GitMarker: Send + Sync {
310    /// Stage `path` and commit it, returning the resulting [`GitMark`].
311    ///
312    /// `repo` is the absolute filesystem path to the (non-bare) pod repo;
313    /// `path` is the repo-relative path written; `agent_did` is recorded as
314    /// the commit author email; `message` is the commit subject. When there
315    /// is nothing to commit the implementation returns a mark referencing the
316    /// current HEAD without erroring.
317    async fn mark_write(
318        &self,
319        repo: &Path,
320        path: &str,
321        agent_did: &str,
322        message: &str,
323    ) -> Result<GitMark, ProvenanceError>;
324
325    /// Return the current HEAD commit SHA, or `None` for an unborn branch.
326    async fn head(&self, repo: &Path) -> Result<Option<String>, ProvenanceError>;
327}
328
329/// Expensive tier. Server-side (mempool + Bitcoin TX), behind feature `mrc20`.
330///
331/// Defined here; a real implementation lands in Phase 4 (`bitcoin_tx.rs`).
332#[async_trait::async_trait(?Send)]
333pub trait BlockAnchorer: Send + Sync {
334    /// Anchor `state_hash` under `ticker` on `network`, returning the produced
335    /// [`BlockTrailAnchor`]. Implemented by
336    /// `solid-pod-rs-server::mempool::MempoolBlockAnchorer` (builds + broadcasts
337    /// a taproot MRC20 anchoring tx via `bitcoin_tx.rs`).
338    async fn anchor(
339        &self,
340        ticker: &str,
341        state_hash: &str,
342        network: &str,
343    ) -> Result<BlockTrailAnchor, ProvenanceError>;
344
345    /// Verify a previously-produced anchor against the chain / fixtures
346    /// (re-derives the taproot address from the portable proof, then confirms a
347    /// UTXO sits at it).
348    async fn verify(&self, anchor: &BlockTrailAnchor) -> Result<bool, ProvenanceError>;
349}
350
351// ---------------------------------------------------------------------------
352// Composition policy (§2.3, ADR-059 D1/D5)
353// ---------------------------------------------------------------------------
354
355/// When a [`ProvenanceLog::record`] write should incur the expensive Bitcoin
356/// block-trail anchor on top of the always-on git-mark.
357///
358/// The cheap tier (git-mark) runs for *every* policy — these variants only
359/// govern the **opt-in** anchor. Pure, `Copy`, wasm-safe: it carries no I/O.
360///
361/// | Variant | Anchor behaviour |
362/// |--------------|--------------------------------------------------------|
363/// | [`Never`](AnchorPolicy::Never) | git-mark only — no on-chain cost. The default for ordinary writes. |
364/// | [`Always`](AnchorPolicy::Always) | anchor **every** write (commits the git SHA on-chain). Expensive; only for trails where every state must be externally timestamped. |
365/// | [`HighValue`](AnchorPolicy::HighValue) | anchor iff the resource is flagged anchor-worthy (its ACL carries a `ProvenanceAnchor` condition / the caller passes the high-value flag). Settlement receipts, elevation/ACSP decisions. |
366/// | [`Epoch`](AnchorPolicy::Epoch) | accumulate the git SHA into an [`EpochAccumulator`]; the batch root is anchored **once** on epoch close (one Bitcoin tx notarises many commits — ADR-059 D5). |
367///
368/// An anchor is attempted only when the policy says so **and** the
369/// [`ProvenanceLog`] was built with an anchorer ([`ProvenanceLog::anchorer`]
370/// is `Some`). With `anchorer: None` (the wasm / no-Bitcoin pod) every policy
371/// degrades to git-mark-only, silently.
372#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
373pub enum AnchorPolicy {
374    /// git-mark only; never anchor. The default for ordinary pod writes.
375    #[default]
376    Never,
377    /// Anchor every write — the git commit SHA is committed on-chain each time.
378    Always,
379    /// Anchor only when the resource is flagged high-value (ACL carries a
380    /// `ProvenanceAnchor` condition). Otherwise git-mark only.
381    HighValue,
382    /// Accumulate the commit into the current epoch; the epoch's Merkle root is
383    /// anchored once on close (amortised on-chain cost — ADR-059 D5).
384    Epoch,
385}
386
387impl AnchorPolicy {
388    /// Whether *this write* should be anchored inline (i.e. produce a
389    /// [`BlockTrailAnchor`] on the returned mark), given whether the resource
390    /// is flagged high-value.
391    ///
392    /// - `Never` / `Epoch` ⇒ never inline (`Epoch` defers to the accumulator).
393    /// - `Always` ⇒ always inline.
394    /// - `HighValue` ⇒ inline iff `high_value`.
395    #[must_use]
396    pub fn anchors_inline(self, high_value: bool) -> bool {
397        match self {
398            AnchorPolicy::Never | AnchorPolicy::Epoch => false,
399            AnchorPolicy::Always => true,
400            AnchorPolicy::HighValue => high_value,
401        }
402    }
403}
404
405// ---------------------------------------------------------------------------
406// Composition log (§2.2 `ProvenanceLog`, §2.3 composition rule)
407// ---------------------------------------------------------------------------
408
409/// The composition point for the two provenance tiers (master-plan §2.2/§2.3,
410/// ADR-059 D1).
411///
412/// A `ProvenanceLog` always holds the cheap-tier [`GitMarker`] and *optionally*
413/// the expensive-tier [`BlockAnchorer`]. [`record`](ProvenanceLog::record)
414/// implements the **cheap-always, expensive-opt-in** rule:
415///
416/// 1. **Always** `marker.mark_write()` → [`GitMark`] (every write becomes a
417///    commit; we capture the SHA).
418/// 2. **Conditionally** `anchorer.anchor()` when the [`AnchorPolicy`] says this
419///    write anchors inline AND an anchorer is present. The anchor's
420///    `state_hash` is set to the git commit SHA — so the Bitcoin UTXO commits
421///    to the git history, **binding the two tiers into one chain** (§2.3).
422///
423/// The returned [`ProvenanceMark`] carries the git-mark always and the anchor
424/// when one was produced. Persisting the PROV-O sidecar and emitting the
425/// `Updates-via` notification (step 3) is the server's job — kept out of this
426/// pure surface so it compiles for wasm.
427///
428/// ## wasm32 safety
429///
430/// `Arc<dyn GitMarker>` / `Arc<dyn BlockAnchorer>` are `?Send` trait objects;
431/// the type holds no runtime. On wasm the pod constructs it with a no-op marker
432/// and `anchorer: None`, so `record` is git-mark-only and never reaches any
433/// Bitcoin I/O.
434#[derive(Clone)]
435pub struct ProvenanceLog {
436    /// Cheap tier — always invoked. The native server injects
437    /// `solid-pod-rs-git`'s `ShellGitMarker`; wasm injects a no-op.
438    pub marker: Arc<dyn GitMarker>,
439    /// Expensive tier — optional. `None` in pods that do not pay for Bitcoin
440    /// anchoring (and always `None` on wasm).
441    pub anchorer: Option<Arc<dyn BlockAnchorer>>,
442}
443
444impl std::fmt::Debug for ProvenanceLog {
445    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
446        f.debug_struct("ProvenanceLog")
447            .field("marker", &"Arc<dyn GitMarker>")
448            .field(
449                "anchorer",
450                &self.anchorer.as_ref().map(|_| "Arc<dyn BlockAnchorer>"),
451            )
452            .finish()
453    }
454}
455
456/// Descriptor of a single pod write passed to [`ProvenanceLog::record`].
457///
458/// Borrowed (no allocation on the hot path), mirroring
459/// [`crate::wac::conditions::RequestContext`]. Bundles the write identity
460/// (repo/path/agent/message), the expensive-tier [`AnchorPolicy`] + its
461/// `high_value` flag, and the trail coordinates (`ticker`/`network`) an anchor
462/// targets. The trail fields are ignored unless the policy actually anchors.
463#[derive(Debug, Clone, Copy)]
464pub struct WriteRecord<'a> {
465    /// Absolute filesystem path to the (non-bare) pod repo.
466    pub repo: &'a Path,
467    /// Repo-relative path of the resource written.
468    pub path: &'a str,
469    /// `did:nostr` of the writer (NIP-98 principal), or an anonymous marker.
470    pub agent_did: &'a str,
471    /// Commit subject (the LDP method + path).
472    pub message: &'a str,
473    /// Expensive-tier policy (see [`AnchorPolicy`]).
474    pub policy: AnchorPolicy,
475    /// Whether the resource is flagged high-value (ACL `ProvenanceAnchor`).
476    pub high_value: bool,
477    /// Trail ticker to anchor against (used only when anchoring).
478    pub ticker: &'a str,
479    /// Bitcoin network of the trail (used only when anchoring).
480    pub network: &'a str,
481    /// Unix seconds stamped onto the produced mark.
482    pub created: u64,
483}
484
485impl ProvenanceLog {
486    /// Construct a git-mark-only log (no Bitcoin tier). The common case for
487    /// ordinary pods and the only shape available on wasm.
488    #[must_use]
489    pub fn new(marker: Arc<dyn GitMarker>) -> Self {
490        Self {
491            marker,
492            anchorer: None,
493        }
494    }
495
496    /// Construct a log with both tiers wired.
497    #[must_use]
498    pub fn with_anchorer(marker: Arc<dyn GitMarker>, anchorer: Arc<dyn BlockAnchorer>) -> Self {
499        Self {
500            marker,
501            anchorer: Some(anchorer),
502        }
503    }
504
505    /// Record a pod resource write across both tiers (the composition rule).
506    ///
507    /// The write is described by a [`WriteRecord`]. Always commits (cheap tier).
508    /// Then, iff `policy.anchors_inline(high_value)` — both carried by the
509    /// [`WriteRecord`] — AND an anchorer is present, anchors the **git commit
510    /// SHA** under the record's `ticker`/`network`, attaching the
511    /// [`BlockTrailAnchor`] to the returned mark. The anchor's `state_hash` is
512    /// the commit SHA, binding git ↔ Bitcoin (master-plan §2.3).
513    ///
514    /// For [`AnchorPolicy::Epoch`] this method never anchors inline — the caller
515    /// feeds the returned `git.commit_sha` into an [`EpochAccumulator`] and
516    /// anchors the batch root on epoch close.
517    ///
518    /// Errors from the **cheap** tier propagate (the git-mark is the contract).
519    /// Errors from the **expensive** tier are returned too, so the caller can
520    /// decide its own best-effort policy — the server hook logs+swallows them
521    /// (a failed anchor must never fail the LDP write), exactly as it does for
522    /// the git-mark.
523    pub async fn record(&self, rec: WriteRecord<'_>) -> Result<ProvenanceMark, ProvenanceError> {
524        // 1. Cheap tier — ALWAYS. A failure here is a hard error: the git-mark
525        //    is the always-on contract.
526        let git = self
527            .marker
528            .mark_write(rec.repo, rec.path, rec.agent_did, rec.message)
529            .await?;
530
531        // 2. Expensive tier — opt-in. Only when the policy anchors this write
532        //    inline AND an anchorer is wired. The anchored state_hash IS the
533        //    git commit SHA — the Bitcoin UTXO now commits to the git history
534        //    (master-plan §2.3 "binds both primitives into one chain").
535        let anchor = if rec.policy.anchors_inline(rec.high_value) {
536            match &self.anchorer {
537                Some(a) => Some(a.anchor(rec.ticker, &git.commit_sha, rec.network).await?),
538                None => None,
539            }
540        } else {
541            None
542        };
543
544        Ok(ProvenanceMark {
545            resource: path_to_resource(rec.path),
546            git,
547            anchor,
548            agent_did: rec.agent_did.to_string(),
549            created: rec.created,
550        })
551    }
552}
553
554/// Normalise a repo-relative `path` into the pod-relative `resource` form a
555/// [`ProvenanceMark`] records (leading slash). Idempotent for already-absolute
556/// inputs.
557fn path_to_resource(path: &str) -> String {
558    if path.starts_with('/') {
559        path.to_string()
560    } else {
561        format!("/{path}")
562    }
563}
564
565// ---------------------------------------------------------------------------
566// Epoch Merkle-root anchoring (§2.3, ADR-059 D5) — pure, wasm-safe
567// ---------------------------------------------------------------------------
568
569/// Compute a binary SHA-256 Merkle root over `leaves` (each a 32-byte digest),
570/// duplicating the last node on an odd level (Bitcoin-style). Returns the
571/// all-zero digest for an empty input.
572///
573/// Pure and wasm-safe — uses only the always-compiled `sha2` dependency. Leaves
574/// are hashed *as given*; callers pass `sha256(commit_sha)` so the tree commits
575/// to the exact commit identifiers.
576fn merkle_root(leaves: &[[u8; 32]]) -> [u8; 32] {
577    if leaves.is_empty() {
578        return [0u8; 32];
579    }
580    let mut level: Vec<[u8; 32]> = leaves.to_vec();
581    while level.len() > 1 {
582        let mut next = Vec::with_capacity(level.len().div_ceil(2));
583        let mut i = 0;
584        while i < level.len() {
585            let left = level[i];
586            // Duplicate the last node when the level is odd.
587            let right = if i + 1 < level.len() {
588                level[i + 1]
589            } else {
590                left
591            };
592            let mut h = Sha256::new();
593            h.update(left);
594            h.update(right);
595            next.push(h.finalize().into());
596            i += 2;
597        }
598        level = next;
599    }
600    level[0]
601}
602
603/// Hash one leaf value (a git commit SHA, as text) into the Merkle leaf digest.
604fn merkle_leaf(commit_sha: &str) -> [u8; 32] {
605    Sha256::digest(commit_sha.as_bytes()).into()
606}
607
608/// A Merkle inclusion proof: the sibling digests from leaf to root, each tagged
609/// with whether the sibling sits on the **right** of the running hash at that
610/// level. Verified with [`EpochAccumulator::verify_inclusion`].
611#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
612pub struct MerkleProof {
613    /// Hex of the leaf value's digest (`sha256(commit_sha)`).
614    pub leaf: String,
615    /// Sibling steps from the leaf upward: `(sibling_hex, sibling_is_right)`.
616    pub siblings: Vec<(String, bool)>,
617}
618
619/// Accumulates git commit SHAs into an epoch and, on close, yields the single
620/// Merkle root to anchor (ADR-059 D5 — *one Bitcoin tx notarises many
621/// commits*).
622///
623/// Writes whose [`AnchorPolicy`] is [`Epoch`](AnchorPolicy::Epoch) call
624/// [`push`](EpochAccumulator::push) with the commit SHA the git-mark produced.
625/// When the configured commit-count threshold is reached
626/// ([`is_full`](EpochAccumulator::is_full)), the caller [`close`s](EpochAccumulator::close)
627/// the epoch to obtain the root (hex) and the batched SHAs, anchors the root
628/// **once** via a [`BlockAnchorer`], and starts a fresh epoch. A per-commit
629/// [`inclusion_proof`](EpochAccumulator::inclusion_proof) lets any commit be
630/// proven against the anchored root without re-anchoring.
631///
632/// Pure and wasm-safe: the accumulator and Merkle maths carry no I/O; the
633/// single anchor call is the caller's, via the (optional) anchorer.
634#[derive(Debug, Clone)]
635pub struct EpochAccumulator {
636    /// Commit SHAs collected so far this epoch (insertion order = leaf order).
637    commits: Vec<String>,
638    /// Commit-count threshold at which the epoch is considered full. Operator
639    /// policy (master-plan §5: "ACL writes epoch-only to bound cost").
640    threshold: usize,
641}
642
643/// The sealed result of closing an epoch: the Merkle root to anchor plus the
644/// batch of commit SHAs it commits to.
645#[derive(Debug, Clone, PartialEq, Eq)]
646pub struct ClosedEpoch {
647    /// Hex SHA-256 Merkle root over the epoch's commit-SHA leaves — the single
648    /// value anchored on-chain for the whole batch.
649    pub root: String,
650    /// The commit SHAs this root notarises (leaf order).
651    pub commits: Vec<String>,
652}
653
654impl EpochAccumulator {
655    /// New, empty epoch with a close `threshold` (clamped to ≥ 1).
656    #[must_use]
657    pub fn new(threshold: usize) -> Self {
658        Self {
659            commits: Vec::new(),
660            threshold: threshold.max(1),
661        }
662    }
663
664    /// Add a git commit SHA to the current epoch.
665    pub fn push(&mut self, commit_sha: impl Into<String>) {
666        self.commits.push(commit_sha.into());
667    }
668
669    /// Number of commits accumulated this epoch.
670    #[must_use]
671    pub fn len(&self) -> usize {
672        self.commits.len()
673    }
674
675    /// Whether the epoch holds no commits.
676    #[must_use]
677    pub fn is_empty(&self) -> bool {
678        self.commits.is_empty()
679    }
680
681    /// The configured close threshold.
682    #[must_use]
683    pub fn threshold(&self) -> usize {
684        self.threshold
685    }
686
687    /// Whether the epoch has reached its close threshold (time to anchor).
688    #[must_use]
689    pub fn is_full(&self) -> bool {
690        self.commits.len() >= self.threshold
691    }
692
693    /// The current Merkle root (hex) over the accumulated commits, without
694    /// draining. Returns `None` for an empty epoch (nothing to anchor).
695    #[must_use]
696    pub fn root(&self) -> Option<String> {
697        if self.commits.is_empty() {
698            return None;
699        }
700        let leaves: Vec<[u8; 32]> = self.commits.iter().map(|c| merkle_leaf(c)).collect();
701        Some(hex::encode(merkle_root(&leaves)))
702    }
703
704    /// Seal the epoch: compute the root, return it with the batched commit SHAs,
705    /// and **drain** the accumulator so a fresh epoch begins. Returns `None`
706    /// (and drains nothing) for an empty epoch.
707    pub fn close(&mut self) -> Option<ClosedEpoch> {
708        if self.commits.is_empty() {
709            return None;
710        }
711        let commits = std::mem::take(&mut self.commits);
712        let leaves: Vec<[u8; 32]> = commits.iter().map(|c| merkle_leaf(c)).collect();
713        let root = hex::encode(merkle_root(&leaves));
714        Some(ClosedEpoch { root, commits })
715    }
716
717    /// Produce an inclusion proof for the commit at leaf `index` against the
718    /// *current* set of accumulated commits. `None` if `index` is out of range.
719    ///
720    /// The proof verifies against the root produced by [`root`](Self::root) /
721    /// [`close`](Self::close) over the same commit set — i.e. against the value
722    /// anchored on-chain.
723    #[must_use]
724    pub fn inclusion_proof(&self, index: usize) -> Option<MerkleProof> {
725        let n = self.commits.len();
726        if index >= n {
727            return None;
728        }
729        let mut level: Vec<[u8; 32]> = self.commits.iter().map(|c| merkle_leaf(c)).collect();
730        let leaf_hex = hex::encode(level[index]);
731        let mut idx = index;
732        let mut siblings: Vec<(String, bool)> = Vec::new();
733        while level.len() > 1 {
734            let sibling_idx = if idx.is_multiple_of(2) {
735                idx + 1
736            } else {
737                idx - 1
738            };
739            // On an odd level the rightmost node is paired with itself.
740            let sib = if sibling_idx < level.len() {
741                level[sibling_idx]
742            } else {
743                level[idx]
744            };
745            let sibling_is_right = idx.is_multiple_of(2);
746            siblings.push((hex::encode(sib), sibling_is_right));
747
748            // Build the next level.
749            let mut next = Vec::with_capacity(level.len().div_ceil(2));
750            let mut i = 0;
751            while i < level.len() {
752                let left = level[i];
753                let right = if i + 1 < level.len() {
754                    level[i + 1]
755                } else {
756                    left
757                };
758                let mut h = Sha256::new();
759                h.update(left);
760                h.update(right);
761                next.push(h.finalize().into());
762                i += 2;
763            }
764            level = next;
765            idx /= 2;
766        }
767        Some(MerkleProof {
768            leaf: leaf_hex,
769            siblings,
770        })
771    }
772
773    /// Verify a [`MerkleProof`] against an expected `root_hex` (the anchored
774    /// root). Recomputes the path and compares — no accumulator state needed,
775    /// so a verifier can check inclusion with only the proof + the on-chain
776    /// root.
777    #[must_use]
778    pub fn verify_inclusion(proof: &MerkleProof, root_hex: &str) -> bool {
779        let Ok(mut acc) = hex::decode(&proof.leaf) else {
780            return false;
781        };
782        if acc.len() != 32 {
783            return false;
784        }
785        for (sib_hex, sib_is_right) in &proof.siblings {
786            let Ok(sib) = hex::decode(sib_hex) else {
787                return false;
788            };
789            if sib.len() != 32 {
790                return false;
791            }
792            let mut h = Sha256::new();
793            if *sib_is_right {
794                h.update(&acc);
795                h.update(&sib);
796            } else {
797                h.update(&sib);
798                h.update(&acc);
799            }
800            acc = h.finalize().to_vec();
801        }
802        hex::encode(acc) == root_hex
803    }
804}
805
806// ---------------------------------------------------------------------------
807// PROV-O serialiser (§2.3 step 3, D7)
808// ---------------------------------------------------------------------------
809
810/// Escape a string for inclusion inside a Turtle double-quoted literal
811/// (RDF 1.1 Turtle §2.5.3 / §6.4 string escapes).
812fn ttl_escape(s: &str) -> String {
813    let mut out = String::with_capacity(s.len());
814    for c in s.chars() {
815        match c {
816            '\\' => out.push_str("\\\\"),
817            '"' => out.push_str("\\\""),
818            '\n' => out.push_str("\\n"),
819            '\r' => out.push_str("\\r"),
820            '\t' => out.push_str("\\t"),
821            _ => out.push(c),
822        }
823    }
824    out
825}
826
827/// Render `secs` (Unix seconds) as an `xsd:dateTime` literal in UTC.
828///
829/// Pure, allocation-light, and wasm-safe — avoids dragging `chrono`'s
830/// formatting into the pure surface (the crate already depends on `chrono`
831/// but we keep this self-contained and deterministic for the golden test).
832fn xsd_datetime(secs: u64) -> String {
833    // Civil-from-days (Howard Hinnant's algorithm) — exact, no leap tables.
834    let days = (secs / 86_400) as i64;
835    let rem = (secs % 86_400) as i64;
836    let (hh, mm, ss) = (rem / 3600, (rem % 3600) / 60, rem % 60);
837
838    let z = days + 719_468;
839    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
840    let doe = z - era * 146_097;
841    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
842    let y = yoe + era * 400;
843    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
844    let mp = (5 * doy + 2) / 153;
845    let d = doy - (153 * mp + 2) / 5 + 1;
846    let m = if mp < 10 { mp + 3 } else { mp - 9 };
847    let y = if m <= 2 { y + 1 } else { y };
848
849    format!("{y:04}-{m:02}-{d:02}T{hh:02}:{mm:02}:{ss:02}Z")
850}
851
852/// Produce a minimal, correct PROV-O Turtle sidecar for a [`ProvenanceMark`].
853///
854/// The mark is modelled as a `prov:Activity` (the write) that
855/// `prov:generated` the resource entity, was performed by the agent
856/// (`prov:wasAssociatedWith`), and is identified by its git commit SHA. The
857/// resource entity records `prov:wasGeneratedBy` the activity. When a
858/// block-trail anchor is present it is emitted as an associated entity bearing
859/// the txid/state-hash so the sidecar carries both tiers.
860///
861/// Kept deliberately small: stable prefix block, one activity, one entity, one
862/// agent, optional anchor entity. Round-trip-safe with the unit tests below.
863pub fn prov_ttl(mark: &ProvenanceMark) -> String {
864    let sha = &mark.git.commit_sha;
865    let resource = ttl_escape(&mark.resource);
866    let agent = ttl_escape(&mark.agent_did);
867    let branch = ttl_escape(&mark.git.branch);
868    let repo = ttl_escape(&mark.git.repo);
869    let when = xsd_datetime(mark.created);
870
871    let mut ttl = String::new();
872    ttl.push_str("@prefix prov: <http://www.w3.org/ns/prov#> .\n");
873    ttl.push_str("@prefix xsd:  <http://www.w3.org/2001/XMLSchema#> .\n");
874    ttl.push_str("@prefix git:  <https://w3id.org/git#> .\n");
875    ttl.push_str("@prefix bt:   <https://blocktrails.org/ns#> .\n\n");
876
877    // Activity: the write, identified by the commit it produced.
878    ttl.push_str(&format!("<urn:git:commit:{sha}> a prov:Activity ;\n"));
879    ttl.push_str(&format!("    prov:generated <{resource}> ;\n"));
880    ttl.push_str(&format!("    prov:wasAssociatedWith <{agent}> ;\n"));
881    ttl.push_str(&format!(
882        "    prov:endedAtTime \"{when}\"^^xsd:dateTime ;\n"
883    ));
884    ttl.push_str(&format!("    git:commit \"{sha}\" ;\n"));
885    ttl.push_str(&format!("    git:branch \"{branch}\" ;\n"));
886    ttl.push_str(&format!("    git:repo \"{repo}\" "));
887    if let Some(parent) = &mark.git.parent {
888        let parent = ttl_escape(parent);
889        ttl.push_str(&format!(";\n    git:parent \"{parent}\" .\n"));
890    } else {
891        ttl.push_str(".\n");
892    }
893
894    // Entity: the generated resource.
895    ttl.push('\n');
896    ttl.push_str(&format!("<{resource}> a prov:Entity ;\n"));
897    ttl.push_str(&format!(
898        "    prov:wasGeneratedBy <urn:git:commit:{sha}> ;\n"
899    ));
900    ttl.push_str(&format!("    prov:wasAttributedTo <{agent}> .\n"));
901
902    // Agent.
903    ttl.push('\n');
904    ttl.push_str(&format!("<{agent}> a prov:Agent .\n"));
905
906    // Optional anchor entity (expensive tier).
907    if let Some(a) = &mark.anchor {
908        let txid = ttl_escape(&a.txid);
909        let ticker = ttl_escape(&a.ticker);
910        let state_hash = ttl_escape(&a.state_hash);
911        let network = ttl_escape(&a.network);
912        ttl.push('\n');
913        ttl.push_str(&format!("<urn:bt:tx:{txid}:{}> a prov:Entity ;\n", a.vout));
914        ttl.push_str(&format!(
915            "    prov:wasDerivedFrom <urn:git:commit:{sha}> ;\n"
916        ));
917        ttl.push_str(&format!("    bt:ticker \"{ticker}\" ;\n"));
918        ttl.push_str(&format!("    bt:stateHash \"{state_hash}\" ;\n"));
919        ttl.push_str(&format!("    bt:network \"{network}\" ;\n"));
920        ttl.push_str(&format!("    bt:txid \"{txid}\" ;\n"));
921        ttl.push_str(&format!("    bt:vout \"{}\"^^xsd:integer ", a.vout));
922        if let Some(h) = a.blockheight {
923            ttl.push_str(&format!(";\n    bt:blockheight \"{h}\"^^xsd:integer .\n"));
924        } else {
925            ttl.push_str(".\n");
926        }
927    }
928
929    ttl
930}
931
932// ---------------------------------------------------------------------------
933// Tests
934// ---------------------------------------------------------------------------
935
936#[cfg(test)]
937mod tests {
938    use super::*;
939
940    fn sample_git() -> GitMark {
941        GitMark {
942            commit_sha: "a1b2c3d4e5f60718293a4b5c6d7e8f9001122334".into(),
943            repo: "deadbeef".into(),
944            branch: "main".into(),
945            parent: Some("00112233445566778899aabbccddeeff00112233".into()),
946        }
947    }
948
949    fn sample_mark() -> ProvenanceMark {
950        ProvenanceMark {
951            resource: "/notes/hello.ttl".into(),
952            git: sample_git(),
953            anchor: None,
954            agent_did: "did:nostr:abcdef".into(),
955            created: 1_750_000_000,
956        }
957    }
958
959    #[test]
960    fn git_mark_round_trips() {
961        let g = sample_git();
962        let json = serde_json::to_string(&g).unwrap();
963        let back: GitMark = serde_json::from_str(&json).unwrap();
964        assert_eq!(g, back);
965    }
966
967    // ── ADR-124: gitmark.json / blocktrails.json substrate envelopes ──────
968
969    #[test]
970    fn gitmark_envelope_has_exactly_five_keys() {
971        // C7 + CI invariant #6: gitmark.json is EXACTLY {@id, genesis, nick,
972        // package, repository}. No @context/@type/commit/parent.
973        let g = sample_git();
974        let env = g.to_gitmark_envelope(0, &g.commit_sha, "gitmark", "./package.json");
975        let v: serde_json::Value = serde_json::to_value(&env).unwrap();
976        let obj = v.as_object().unwrap();
977        let mut keys: Vec<&str> = obj.keys().map(String::as_str).collect();
978        keys.sort_unstable();
979        assert_eq!(
980            keys,
981            ["@id", "genesis", "nick", "package", "repository"],
982            "gitmark.json must be exactly the 5-key envelope (C7)"
983        );
984        // The four invented keys must be absent.
985        for forbidden in ["@context", "@type", "commit", "parent"] {
986            assert!(
987                !obj.contains_key(forbidden),
988                "gitmark.json must NOT carry `{forbidden}` (not in ground truth)"
989            );
990        }
991    }
992
993    #[test]
994    fn gitmark_envelope_projection_values() {
995        let g = sample_git();
996        // Genesis mark: genesis == @id.
997        let env = g.to_gitmark_envelope(0, &g.commit_sha, "pod", "./package.json");
998        assert_eq!(env.id, format!("gitmark:{}:0", g.commit_sha));
999        assert_eq!(env.genesis, format!("gitmark:{}:0", g.commit_sha));
1000        assert_eq!(env.repository, "./");
1001        // Non-genesis mark with a distinct genesis SHA + vout.
1002        let env2 = g.to_gitmark_envelope(2, "00".repeat(20).as_str(), "pod", "./package.json");
1003        assert_eq!(env2.id, format!("gitmark:{}:2", g.commit_sha));
1004        assert_eq!(env2.genesis, format!("gitmark:{}:0", "00".repeat(20)));
1005        assert_ne!(env2.id, env2.genesis);
1006    }
1007
1008    #[test]
1009    fn gitmark_json_matches_carvalho_shape() {
1010        // Mirrors microfed/gitmark.json key set + ordering-agnostic shape.
1011        let g = sample_git();
1012        let json = g
1013            .to_gitmark_json(0, &g.commit_sha, "gitmark", "./package.json")
1014            .unwrap();
1015        let v: serde_json::Value = serde_json::from_str(&json).unwrap();
1016        assert_eq!(v["@id"], format!("gitmark:{}:0", g.commit_sha));
1017        assert_eq!(v["nick"], "gitmark");
1018        assert_eq!(v["package"], "./package.json");
1019        assert_eq!(v["repository"], "./");
1020    }
1021
1022    #[test]
1023    fn blocktrail_envelope_shape() {
1024        // C6 webcontracts reference shape: @type Blocktrail, profile gitmark,
1025        // states[] = commit SHAs, txo[] = UTXO chain.
1026        let g = sample_git();
1027        let txo = vec![BlocktrailTxo {
1028            outpoint: format!("{}:0", "ab".repeat(32)),
1029            blockheight: Some(840_000),
1030        }];
1031        let bt =
1032            BlocktrailEnvelope::new_gitmark_profile(&g.commit_sha, vec![g.commit_sha.clone()], txo);
1033        assert_eq!(bt.type_, "Blocktrail");
1034        assert_eq!(bt.profile, "gitmark");
1035        assert_eq!(bt.id, format!("gitmark:{}:0", g.commit_sha));
1036        assert_eq!(bt.genesis, bt.id);
1037        assert_eq!(bt.states, vec![g.commit_sha.clone()]);
1038        assert_eq!(bt.txo.len(), 1);
1039
1040        // JSON shape: @type / profile / states / txo present.
1041        let v: serde_json::Value =
1042            serde_json::from_str(&bt.to_blocktrails_json().unwrap()).unwrap();
1043        assert_eq!(v["@type"], "Blocktrail");
1044        assert_eq!(v["profile"], "gitmark");
1045        assert!(v["states"].is_array());
1046        assert!(v["txo"].is_array());
1047        assert_eq!(v["txo"][0]["outpoint"], format!("{}:0", "ab".repeat(32)));
1048    }
1049
1050    #[test]
1051    fn blocktrail_envelope_round_trips() {
1052        let bt = BlocktrailEnvelope::new_gitmark_profile(
1053            &"cd".repeat(20),
1054            vec!["aa".repeat(20), "bb".repeat(20)],
1055            vec![
1056                BlocktrailTxo {
1057                    outpoint: "t0:0".into(),
1058                    blockheight: None,
1059                },
1060                BlocktrailTxo {
1061                    outpoint: "t1:0".into(),
1062                    blockheight: Some(1),
1063                },
1064            ],
1065        );
1066        let json = serde_json::to_string(&bt).unwrap();
1067        let back: BlocktrailEnvelope = serde_json::from_str(&json).unwrap();
1068        assert_eq!(bt, back);
1069    }
1070
1071    #[test]
1072    fn provenance_mark_round_trips_without_anchor() {
1073        let m = sample_mark();
1074        let json = serde_json::to_string(&m).unwrap();
1075        // `anchor: None` must be omitted by skip_serializing_if.
1076        assert!(!json.contains("anchor"));
1077        let back: ProvenanceMark = serde_json::from_str(&json).unwrap();
1078        assert_eq!(m, back);
1079    }
1080
1081    #[test]
1082    fn provenance_mark_round_trips_with_anchor() {
1083        let mut m = sample_mark();
1084        m.anchor = Some(BlockTrailAnchor {
1085            ticker: "PROV".into(),
1086            state_hash: "ff".repeat(32),
1087            txid: "ab".repeat(32),
1088            vout: 1,
1089            address: "tb1pexample".into(),
1090            network: "testnet4".into(),
1091            blockheight: Some(840_000),
1092            state_strings: vec!["{\"seq\":0}".into(), "{\"seq\":1}".into()],
1093            pubkey: Some("02".to_string() + &"ab".repeat(32)),
1094        });
1095        let json = serde_json::to_string(&m).unwrap();
1096        let back: ProvenanceMark = serde_json::from_str(&json).unwrap();
1097        assert_eq!(m, back);
1098    }
1099
1100    #[test]
1101    fn block_trail_anchor_defaults_state_strings() {
1102        // state_strings missing in JSON must deserialise to an empty vec.
1103        let json = r#"{
1104            "ticker":"PROV","state_hash":"00","txid":"00","vout":0,
1105            "address":"tb1p","network":"testnet4"
1106        }"#;
1107        let a: BlockTrailAnchor = serde_json::from_str(json).unwrap();
1108        assert!(a.state_strings.is_empty());
1109        assert!(a.blockheight.is_none());
1110    }
1111
1112    #[test]
1113    fn prov_ttl_contains_core_triples() {
1114        let ttl = prov_ttl(&sample_mark());
1115        assert!(ttl.contains("@prefix prov: <http://www.w3.org/ns/prov#> ."));
1116        assert!(ttl.contains("a prov:Activity"));
1117        assert!(ttl.contains("prov:wasGeneratedBy"));
1118        assert!(ttl.contains("prov:wasAssociatedWith <did:nostr:abcdef>"));
1119        assert!(ttl.contains("a prov:Agent"));
1120        // Commit sha appears as the activity id + git:commit literal.
1121        assert!(ttl.contains("<urn:git:commit:a1b2c3d4e5f60718293a4b5c6d7e8f9001122334>"));
1122        assert!(ttl.contains("git:commit \"a1b2c3d4e5f60718293a4b5c6d7e8f9001122334\""));
1123        assert!(ttl.contains("git:branch \"main\""));
1124        assert!(ttl.contains("git:parent \"00112233445566778899aabbccddeeff00112233\""));
1125        // The generated entity is the resource.
1126        assert!(ttl
1127            .contains("<urn:git:commit:a1b2c3d4e5f60718293a4b5c6d7e8f9001122334> a prov:Activity"));
1128        assert!(ttl.contains("prov:generated </notes/hello.ttl>"));
1129    }
1130
1131    #[test]
1132    fn prov_ttl_omits_parent_when_absent() {
1133        let mut m = sample_mark();
1134        m.git.parent = None;
1135        let ttl = prov_ttl(&m);
1136        assert!(!ttl.contains("git:parent"));
1137        // Must still be a well-terminated activity block.
1138        assert!(ttl.contains("git:repo \"deadbeef\" .\n"));
1139    }
1140
1141    #[test]
1142    fn prov_ttl_emits_anchor_block_when_present() {
1143        let mut m = sample_mark();
1144        m.anchor = Some(BlockTrailAnchor {
1145            ticker: "PROV".into(),
1146            state_hash: "deadbeef".into(),
1147            txid: "cafebabe".into(),
1148            vout: 2,
1149            address: "tb1pexample".into(),
1150            network: "testnet4".into(),
1151            blockheight: Some(840_000),
1152            state_strings: vec![],
1153            pubkey: None,
1154        });
1155        let ttl = prov_ttl(&m);
1156        assert!(ttl.contains("<urn:bt:tx:cafebabe:2> a prov:Entity"));
1157        assert!(ttl.contains("bt:ticker \"PROV\""));
1158        assert!(ttl.contains("bt:stateHash \"deadbeef\""));
1159        assert!(ttl.contains("bt:blockheight \"840000\"^^xsd:integer"));
1160        assert!(ttl.contains("prov:wasDerivedFrom <urn:git:commit:"));
1161    }
1162
1163    #[test]
1164    fn prov_ttl_escapes_quotes_and_backslashes() {
1165        let mut m = sample_mark();
1166        m.agent_did = "did:nostr:\"weird\\did".into();
1167        let ttl = prov_ttl(&m);
1168        // The raw quote/backslash must be escaped inside the literal.
1169        assert!(ttl.contains("did:nostr:\\\"weird\\\\did"));
1170    }
1171
1172    #[test]
1173    fn xsd_datetime_known_epoch() {
1174        // 1_750_000_000 == 2025-06-15T15:06:40Z (verified against `date -u -d @1750000000`).
1175        assert_eq!(xsd_datetime(1_750_000_000), "2025-06-15T15:06:40Z");
1176        // Unix epoch.
1177        assert_eq!(xsd_datetime(0), "1970-01-01T00:00:00Z");
1178    }
1179
1180    #[test]
1181    fn provenance_error_display() {
1182        assert_eq!(
1183            ProvenanceError::Git("boom".into()).to_string(),
1184            "git-mark: boom"
1185        );
1186        assert_eq!(
1187            ProvenanceError::InvalidPath("/x.acl".into()).to_string(),
1188            "invalid provenance path: /x.acl"
1189        );
1190    }
1191
1192    // -----------------------------------------------------------------------
1193    // Phase 5: composition (AnchorPolicy + ProvenanceLog) — pure, mocked tiers
1194    // -----------------------------------------------------------------------
1195
1196    use std::sync::atomic::{AtomicUsize, Ordering};
1197
1198    /// In-memory [`GitMarker`] — fabricates a deterministic SHA per call and
1199    /// counts invocations. No subprocess, so it compiles + runs on wasm too.
1200    #[derive(Default)]
1201    struct MockMarker {
1202        calls: AtomicUsize,
1203    }
1204    #[async_trait::async_trait(?Send)]
1205    impl GitMarker for MockMarker {
1206        async fn mark_write(
1207            &self,
1208            _repo: &Path,
1209            path: &str,
1210            _agent_did: &str,
1211            _message: &str,
1212        ) -> Result<GitMark, ProvenanceError> {
1213            let n = self.calls.fetch_add(1, Ordering::SeqCst);
1214            // 40-hex deterministic SHA derived from the call ordinal + path.
1215            let sha =
1216                hex::encode(Sha256::digest(format!("{n}:{path}").as_bytes()))[..40].to_string();
1217            Ok(GitMark {
1218                commit_sha: sha,
1219                repo: "mockpod".into(),
1220                branch: "main".into(),
1221                parent: None,
1222            })
1223        }
1224        async fn head(&self, _repo: &Path) -> Result<Option<String>, ProvenanceError> {
1225            Ok(None)
1226        }
1227    }
1228
1229    /// In-memory [`BlockAnchorer`] — records the `state_hash` it was asked to
1230    /// anchor (so a test can assert the git SHA was bound) and counts calls.
1231    #[derive(Default)]
1232    struct MockAnchorer {
1233        calls: AtomicUsize,
1234        last_state_hash: std::sync::Mutex<Option<String>>,
1235    }
1236    #[async_trait::async_trait(?Send)]
1237    impl BlockAnchorer for MockAnchorer {
1238        async fn anchor(
1239            &self,
1240            ticker: &str,
1241            state_hash: &str,
1242            network: &str,
1243        ) -> Result<BlockTrailAnchor, ProvenanceError> {
1244            self.calls.fetch_add(1, Ordering::SeqCst);
1245            *self.last_state_hash.lock().unwrap() = Some(state_hash.to_string());
1246            Ok(BlockTrailAnchor {
1247                ticker: ticker.into(),
1248                state_hash: state_hash.into(),
1249                txid: "ab".repeat(32),
1250                vout: 0,
1251                address: "tb1pmock".into(),
1252                network: network.into(),
1253                blockheight: None,
1254                state_strings: vec!["{\"seq\":0}".into()],
1255                pubkey: Some("02".to_string() + &"ab".repeat(32)),
1256            })
1257        }
1258        async fn verify(&self, _anchor: &BlockTrailAnchor) -> Result<bool, ProvenanceError> {
1259            Ok(true)
1260        }
1261    }
1262
1263    fn repo() -> &'static Path {
1264        Path::new("/tmp/mockpod")
1265    }
1266
1267    /// Build a [`WriteRecord`] for the mock tiers (PROV trail on testnet4).
1268    fn rec<'a>(
1269        path: &'a str,
1270        policy: AnchorPolicy,
1271        high_value: bool,
1272        created: u64,
1273    ) -> WriteRecord<'a> {
1274        WriteRecord {
1275            repo: repo(),
1276            path,
1277            agent_did: "did:nostr:a",
1278            message: "PUT",
1279            policy,
1280            high_value,
1281            ticker: "PROV",
1282            network: "testnet4",
1283            created,
1284        }
1285    }
1286
1287    #[test]
1288    fn anchor_policy_inline_matrix() {
1289        assert!(!AnchorPolicy::Never.anchors_inline(true));
1290        assert!(!AnchorPolicy::Never.anchors_inline(false));
1291        assert!(AnchorPolicy::Always.anchors_inline(false));
1292        assert!(AnchorPolicy::Always.anchors_inline(true));
1293        assert!(AnchorPolicy::HighValue.anchors_inline(true));
1294        assert!(!AnchorPolicy::HighValue.anchors_inline(false));
1295        // Epoch never anchors inline — it defers to the accumulator.
1296        assert!(!AnchorPolicy::Epoch.anchors_inline(true));
1297        assert_eq!(AnchorPolicy::default(), AnchorPolicy::Never);
1298    }
1299
1300    #[tokio::test]
1301    async fn record_cheap_write_is_git_mark_only() {
1302        // Never policy ⇒ git-mark only, no anchor, anchorer untouched.
1303        let marker = Arc::new(MockMarker::default());
1304        let anchorer = Arc::new(MockAnchorer::default());
1305        let log = ProvenanceLog::with_anchorer(marker.clone(), anchorer.clone());
1306        let mark = log
1307            .record(rec(
1308                "notes/a.ttl",
1309                AnchorPolicy::Never,
1310                false,
1311                1_750_000_000,
1312            ))
1313            .await
1314            .unwrap();
1315        assert!(mark.anchor.is_none(), "cheap write must carry no anchor");
1316        assert_eq!(mark.resource, "/notes/a.ttl");
1317        assert_eq!(
1318            marker.calls.load(Ordering::SeqCst),
1319            1,
1320            "git-mark always runs"
1321        );
1322        assert_eq!(
1323            anchorer.calls.load(Ordering::SeqCst),
1324            0,
1325            "anchorer must NOT be called"
1326        );
1327    }
1328
1329    #[tokio::test]
1330    async fn record_high_value_write_carries_git_mark_and_anchor() {
1331        // HighValue + high_value=true ⇒ BOTH tiers present, and the anchor's
1332        // state_hash IS the git commit SHA (the two tiers are bound).
1333        let marker = Arc::new(MockMarker::default());
1334        let anchorer = Arc::new(MockAnchorer::default());
1335        let log = ProvenanceLog::with_anchorer(marker.clone(), anchorer.clone());
1336        let mark = log
1337            .record(rec(
1338                "receipts/r1.ttl",
1339                AnchorPolicy::HighValue,
1340                true,
1341                1_750_000_000,
1342            ))
1343            .await
1344            .unwrap();
1345        let anchor = mark.anchor.expect("high-value write must carry an anchor");
1346        assert_eq!(
1347            anchor.state_hash, mark.git.commit_sha,
1348            "anchor must commit to the git SHA (binds the two tiers — §2.3)"
1349        );
1350        assert_eq!(anchorer.calls.load(Ordering::SeqCst), 1);
1351        assert_eq!(
1352            anchorer.last_state_hash.lock().unwrap().as_deref(),
1353            Some(mark.git.commit_sha.as_str())
1354        );
1355    }
1356
1357    #[tokio::test]
1358    async fn record_high_value_flag_false_is_git_only() {
1359        // HighValue policy but the resource is NOT flagged ⇒ git-mark only.
1360        let marker = Arc::new(MockMarker::default());
1361        let anchorer = Arc::new(MockAnchorer::default());
1362        let log = ProvenanceLog::with_anchorer(marker, anchorer.clone());
1363        let mark = log
1364            .record(rec("notes/x.ttl", AnchorPolicy::HighValue, false, 1))
1365            .await
1366            .unwrap();
1367        assert!(mark.anchor.is_none());
1368        assert_eq!(anchorer.calls.load(Ordering::SeqCst), 0);
1369    }
1370
1371    #[tokio::test]
1372    async fn record_always_anchors_every_write() {
1373        let marker = Arc::new(MockMarker::default());
1374        let anchorer = Arc::new(MockAnchorer::default());
1375        let log = ProvenanceLog::with_anchorer(marker, anchorer.clone());
1376        for i in 0..3 {
1377            let m = log
1378                .record(rec(&format!("s/{i}.ttl"), AnchorPolicy::Always, false, 1))
1379                .await
1380                .unwrap();
1381            assert!(m.anchor.is_some());
1382        }
1383        assert_eq!(anchorer.calls.load(Ordering::SeqCst), 3);
1384    }
1385
1386    #[tokio::test]
1387    async fn record_without_anchorer_degrades_to_git_only() {
1388        // No anchorer (the wasm / no-Bitcoin pod): even Always degrades to
1389        // git-mark only, silently.
1390        let marker = Arc::new(MockMarker::default());
1391        let log = ProvenanceLog::new(marker.clone());
1392        assert!(log.anchorer.is_none());
1393        let mark = log
1394            .record(rec("notes/a.ttl", AnchorPolicy::Always, true, 1))
1395            .await
1396            .unwrap();
1397        assert!(
1398            mark.anchor.is_none(),
1399            "no anchorer ⇒ no anchor regardless of policy"
1400        );
1401        assert_eq!(marker.calls.load(Ordering::SeqCst), 1);
1402    }
1403
1404    #[tokio::test]
1405    async fn record_epoch_defers_anchoring_to_accumulator() {
1406        // Epoch policy: record() never anchors inline; the caller batches the
1407        // SHA and anchors the root once on epoch close.
1408        let marker = Arc::new(MockMarker::default());
1409        let anchorer = Arc::new(MockAnchorer::default());
1410        let log = ProvenanceLog::with_anchorer(marker, anchorer.clone());
1411
1412        let mut epoch = EpochAccumulator::new(3);
1413        let mut shas = Vec::new();
1414        for i in 0..3 {
1415            let m = log
1416                .record(rec(&format!("e/{i}.ttl"), AnchorPolicy::Epoch, true, 1))
1417                .await
1418                .unwrap();
1419            assert!(m.anchor.is_none(), "epoch writes never anchor inline");
1420            epoch.push(m.git.commit_sha.clone());
1421            shas.push(m.git.commit_sha);
1422        }
1423        // No per-write anchors happened.
1424        assert_eq!(anchorer.calls.load(Ordering::SeqCst), 0);
1425
1426        // Epoch is full → close → ONE anchor for the whole batch root.
1427        assert!(epoch.is_full());
1428        let closed = epoch.close().expect("non-empty epoch closes");
1429        assert_eq!(closed.commits, shas);
1430        let anchor = anchorer
1431            .anchor("PROV", &closed.root, "testnet4")
1432            .await
1433            .unwrap();
1434        assert_eq!(
1435            anchorer.calls.load(Ordering::SeqCst),
1436            1,
1437            "ONE anchor notarises N commits"
1438        );
1439        assert_eq!(anchor.state_hash, closed.root);
1440        // Epoch drained — a fresh epoch begins.
1441        assert!(epoch.is_empty());
1442    }
1443
1444    // ── Merkle tree: root determinism + inclusion proofs ──────────────────
1445
1446    #[test]
1447    fn merkle_root_empty_and_single() {
1448        assert_eq!(merkle_root(&[]), [0u8; 32]);
1449        let leaf = merkle_leaf("deadbeef");
1450        // A single leaf is its own root.
1451        assert_eq!(merkle_root(&[leaf]), leaf);
1452    }
1453
1454    #[test]
1455    fn merkle_root_is_deterministic_and_order_sensitive() {
1456        let a = merkle_leaf("aaa");
1457        let b = merkle_leaf("bbb");
1458        let r1 = merkle_root(&[a, b]);
1459        let r2 = merkle_root(&[a, b]);
1460        assert_eq!(r1, r2, "deterministic");
1461        let swapped = merkle_root(&[b, a]);
1462        assert_ne!(r1, swapped, "leaf order changes the root");
1463    }
1464
1465    #[test]
1466    fn epoch_root_matches_close_root() {
1467        let mut e = EpochAccumulator::new(10);
1468        for i in 0..5 {
1469            e.push(format!("commit{i:040}"));
1470        }
1471        let peeked = e.root().unwrap();
1472        let closed = e.close().unwrap();
1473        assert_eq!(peeked, closed.root, "root() peek == close() root");
1474    }
1475
1476    #[test]
1477    fn epoch_inclusion_proof_verifies_for_every_leaf() {
1478        // N commits → one root → each commit's inclusion proof verifies.
1479        let n = 7; // odd, to exercise last-node duplication
1480        let mut e = EpochAccumulator::new(n);
1481        for i in 0..n {
1482            e.push(format!("c{i:039}")); // 40-char commit-like ids
1483        }
1484        let root = e.root().unwrap();
1485        for i in 0..n {
1486            let proof = e.inclusion_proof(i).expect("proof for in-range leaf");
1487            assert!(
1488                EpochAccumulator::verify_inclusion(&proof, &root),
1489                "leaf {i} must verify against the anchored root"
1490            );
1491        }
1492        // Out-of-range index → no proof.
1493        assert!(e.inclusion_proof(n).is_none());
1494    }
1495
1496    #[test]
1497    fn epoch_inclusion_proof_rejects_wrong_root_and_tampered_leaf() {
1498        let mut e = EpochAccumulator::new(4);
1499        for i in 0..4 {
1500            e.push(format!("c{i:039}"));
1501        }
1502        let root = e.root().unwrap();
1503        let mut proof = e.inclusion_proof(1).unwrap();
1504        // Wrong root → reject.
1505        assert!(!EpochAccumulator::verify_inclusion(
1506            &proof,
1507            &"00".repeat(32)
1508        ));
1509        // Tampered leaf → reject against the genuine root.
1510        proof.leaf = hex::encode(merkle_leaf("forged"));
1511        assert!(!EpochAccumulator::verify_inclusion(&proof, &root));
1512    }
1513
1514    #[test]
1515    fn epoch_threshold_and_len_tracking() {
1516        let mut e = EpochAccumulator::new(2);
1517        assert_eq!(e.threshold(), 2);
1518        assert!(e.is_empty() && !e.is_full());
1519        e.push("a");
1520        assert_eq!(e.len(), 1);
1521        assert!(!e.is_full());
1522        e.push("b");
1523        assert!(e.is_full(), "reaching threshold ⇒ full");
1524        // Threshold clamps to ≥ 1.
1525        assert_eq!(EpochAccumulator::new(0).threshold(), 1);
1526    }
1527
1528    #[test]
1529    fn empty_epoch_close_is_none() {
1530        let mut e = EpochAccumulator::new(3);
1531        assert!(e.close().is_none());
1532        assert!(e.root().is_none());
1533    }
1534
1535    #[test]
1536    fn merkle_proof_round_trips() {
1537        let p = MerkleProof {
1538            leaf: hex::encode(merkle_leaf("x")),
1539            siblings: vec![("ab".repeat(32), true), ("cd".repeat(32), false)],
1540        };
1541        let json = serde_json::to_string(&p).unwrap();
1542        let back: MerkleProof = serde_json::from_str(&json).unwrap();
1543        assert_eq!(p, back);
1544    }
1545
1546    #[test]
1547    fn anchor_policy_round_trips() {
1548        for p in [
1549            AnchorPolicy::Never,
1550            AnchorPolicy::Always,
1551            AnchorPolicy::HighValue,
1552            AnchorPolicy::Epoch,
1553        ] {
1554            let json = serde_json::to_string(&p).unwrap();
1555            let back: AnchorPolicy = serde_json::from_str(&json).unwrap();
1556            assert_eq!(p, back);
1557        }
1558    }
1559}