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