diff --git a/tinycloud-threshold/README.md b/tinycloud-threshold/README.md
index 9d7aa58e..2c62e4b4 100644
--- a/tinycloud-threshold/README.md
+++ b/tinycloud-threshold/README.md
@@ -4,7 +4,7 @@ BLS12-381 identity-based wrapping of 32-byte data keys and context-bound encrypt
The crate is a workspace member with `publish = false`. No other workspace crate depends on it; the node server remains the default member. Release-plz neither releases nor publishes it.
-## Implemented surface through S0.5
+## Implemented surface through S0.6
- Bounded, canonical scalar, point, identity, ciphertext, request and share encodings.
- Boneh–Franklin FullIdent wrapping and unwrapping with the TinyCloud domains in [SPEC.md](SPEC.md).
@@ -18,8 +18,9 @@ The crate is a workspace member with `publish = false`. No other workspace crate
- Genesis and re-key DKG (GJKR New-DKG) over owner-closed rounds, the re-key under an owner-authorized `re-key` ceremony pinned to the current certificate with the genesis fault budget: consume-self member states for dealing, complaints, answers, Feldman extraction, the common R5 round and R6 reconstruction, and `dkg_verify_transcript`, which recomputes the outcome from the raw signed round records.
- Redistribution for reshare and refresh over owner-closed rounds R1–R3: Feldman dealings from the current certificate's members to an authorized cohort with a new threshold, complaints and answers, deterministic selection of the first t eligible dealers, and `reshare_verify_transcript`. The master public key is unchanged; the output is a candidate for the successor certificate.
- A real-ceremony chain test: genesis 2-of-3, reshare to 3-of-4 and back to 2-of-3, refresh, re-key, and further reshares and re-keys, with a certificate, readiness receipts and activation at every generation and each retired generation's shares refused in the next context.
+- A durable share store: `CandidateShare::to_bytes`/`from_bytes` (canonical bytes; the codec guards its own secret intermediates), one file per generation under a per-network directory, an exclusive per-network open lock, install before any readiness receipt, a pin recomputed from activations verified on every open, retirement whose `state` commit always precedes the unlink, and a durably-recorded freeze on activated equivocation that survives the loss of the fork evidence itself.
-`VerifiedCertificate` supplies `KeyContext` and `PublicKeySet`; `KeyShare::bind` validates a candidate against its certificate. Raw candidate-scalar construction remains crate-private, and the dealer exists only under `#[cfg(test)]`. Redistribution does not erase earlier shares; old and new shares carry different key contexts. Durable storage, retirement and node-runtime paths remain separate slices. Their contracts and the implemented canonical byte layouts are recorded in [SPEC.md](SPEC.md).
+`VerifiedCertificate` supplies `KeyContext` and `PublicKeySet`; `KeyShare::bind` validates a candidate against its certificate. Raw candidate-scalar construction remains crate-private, and the dealer exists only under `#[cfg(test)]`. Redistribution does not erase earlier shares; old and new shares carry different key contexts. The `store` module implements the file-volume durable share store and retirement; node-runtime wiring remains a separate slice. Their contracts and the implemented canonical byte layouts are recorded in [SPEC.md](SPEC.md).
The §3.6 request-digest amendment is explicit in SPEC.md. It makes an identity or transport-key transplant a context mismatch before share equations are evaluated.
diff --git a/tinycloud-threshold/SPEC.md b/tinycloud-threshold/SPEC.md
index 2c8f189f..cd939205 100644
--- a/tinycloud-threshold/SPEC.md
+++ b/tinycloud-threshold/SPEC.md
@@ -231,7 +231,7 @@ All integer fields below are big-endian. `lp` is u32 length followed by bytes.
- No S0.1 public decoder consumes a member list; lists introduced in later slices use the §3.2 sorted/count rules.
- An availability budget counts all unavailable ready signers, including dishonest claims: `q - b >= t′`. All four ready holders in 3-of-4 support one unavailable holder, not two. `WithOutageBudget(0)` is not accepted.
-## Implemented surface through S0.5
+## Implemented surface through S0.6
S0.1 implements §§3.1–3.6 and the typed parameters in §3.0. S0.2 adds the authenticated-context boundaries in §3.0, the certificate and fault-proof APIs in §3.9, and the receipt/activation verification in §3.12:
@@ -248,7 +248,9 @@ S0.4 adds the §3.8 redistribution for reshare and refresh: dealer and recipient
S0.5 runs the §3.7 DKG under a `re-key` ceremony (`dkg_start` and `dkg_verify_transcript` accept kinds `genesis` and `re-key` and refuse reshare and refresh with `InvalidCeremony`), executes the full genesis → reshare → reshare → refresh → re-key chain with real ceremonies, certificates, readiness receipts and activation, and freezes the chain and re-key vectors described in the S0.5 appendix.
-The dealer remains test-only. Durable storage/retirement and node-runtime integration are separate slices. The crate remains an unwired workspace member.
+S0.6 adds the `store` module: the durable file-volume share store of §3.11–3.12 — `CandidateShare` canonical byte persistence, one proof and one share file per certificate, an exclusive per-network open lock, receipt-only-after-install (with the persisted file read back and re-bound), the activated-chain pin recomputed from fully verified proofs at open, member/anchor/pin checks on accepted ceremonies, and positive-proof retirement whose `state` commit precedes every unlink (superseded generations, losing candidates, unnamed files and replayed ceremony ids). Its layouts and rules are in the S0.6 appendix below.
+
+The dealer remains test-only. Node-runtime integration (volume mount, peer/DB pin sources, boot freshness, audit rows) is a separate slice. The crate remains an unwired workspace member.
## Verifier conformance
@@ -545,3 +547,15 @@ A chain of generations is: genesis; then any sequence of reshare, refresh and re
`vectors/v1/dkg-rekey.json` has the shape of `dkg-genesis.json` with `"spec_sections"` naming `S0.5 re-key appendix`, and each case adds `inputs.current_certificate`, the signed pinned certificate (`body` and `owner_signature`, as in `reshare.json`). The cases mirror the genesis cases under a re-key ceremony; names begin `rekey_` (`insufficient_*` keep their names).
`vectors/v1/chain.json` has one case per generation, in chain order, with operation `chain_step`. A case's inputs are the signed ceremony statement and its digest, the initial transcript link, every owner closure and member message, `previous_certificate` (absent for genesis, otherwise the previous case's `certificate`), the candidate `certificate` (`body` and `owner_signature`), one readiness receipt (`member`, `signature`) per holder, `quorum` `all_holders` and the owner's `activation` statement (`body` is the exact 32-byte activation preimage). The outputs are the recomputed kind, epoch, re-key counter, genesis t, threshold, members, mpk, commitments, public shares, transcript hash, certificate digest, activation preimage and the ceremony outcome (`qual` and `reconstructed` for genesis and re-key, `dealers` for reshare and refresh). A verifier recomputes the ceremony outcome from the transcript (S0.3 or S0.4 rules), rebuilds the certificate body from that outcome and the pinned predecessor, requires it to equal the supplied body byte for byte, verifies the owner signature and lineage (S0.2 appendix), verifies every receipt and the activation, and compares its own results with the outputs; it never takes an output field as input to its decision.
+
+## S0.6 durable share-store appendix
+
+The `store` module keeps one member's share material for one network under `
//` (directory mode 0700, files 0600). Every write is write-temp, fsync, rename, fsync-directory; erasure is zero-overwrite, fsync, unlink, fsync-directory. Shares exist only as files named `share-`; `proofs/` holds certificate and activation evidence; `dealt/` is an empty marker; `state` is the single mutable record.
+
+**Candidate share file.** `version:u8=1 || transcript_hash:32 || index:u16 || scalar:32` (67 bytes). Decode is canonical only: exact length, version 1, index 1..=16, scalar < r and non-zero. Secret bytes travel inside `Zeroizing` and `bind` re-verifies them against the certificate before use.
+
+**Proof file.** `version:u8=1 || lp(signed_certificate) || activation_flag:u8 || [lp(activation_bytes) || lp(owner_signature) || receipt_count:u16 || (index:u16 || lp(signature))…]`. The signed certificate is `version:u8=1 || lp(certificate_body) || lp(owner_signature)`.
+
+**State file** (version 3). `version:u8=3 || option(pin:32) || option(candidate: digest:32 || ceremony_id:32 || option(predecessor:32) || receipt_sent:u8) || option(active_ceremony: id:32 || option(predecessor:32)) || seen_count:u32 || seen_ceremony_id:32… || retired_count:u32 || (digest:32 || option(predecessor:32) || erased_at:u64)… || seq:u64 || frozen:u8`. `seen_ceremonies` is history only; `active_ceremony` is the single admissible id, replaced by every fresh accept and cleared on every pin advance, so a superseded or retired id can never install again. Each `retired` entry records the certificate the retired generation descended from: it is reachable — and must survive — only while the pin equals that predecessor; entries keyed to superseded pins are provably dead and compacted away only when the list nears its 65536-entry bound, and an operational mutation that would overflow the bound is refused rather than letting the record grow past it; `open`'s recovery path cannot refuse, so it compacts dead entries before appending — recovery converges toward the bound without evicting reachable records. `frozen` records activated equivocation durably: discovering equivocation writes it before the freeze is honored, so losing the fork's own proof can never thaw the store; only operator recovery rewrites a frozen directory. Versions 1–2 and unknown higher versions are refused at decode; a `receipt_sent` candidate whose share file is missing, a pin whose share or proof is missing, or a chain tip that is also a retired digest is corruption or a freeze — never silent repair.
+
+**Rules.** `open` holds an exclusive advisory `lock` file in the network directory for the store's lifetime, sweeps any `*.tmp-*`/`*.tmp` leftovers, and fsyncs every parent directory it creates. At most one candidate record exists at a time and only for the pinned predecessor (or for genesis before any pin); a `receipt_sent` flag may be set only after proof and share files are durable and the persisted share has been read back and re-bound. Open recomputes the activated chain from `proofs/`, verifying the certificate AND the `AllHolders` activation of every flagged proof (genesis included) before it can extend the pin or count toward a fork; a recorded pin must be a verified activated certificate (chain member or equivocated fork child), anything else is corruption. Activated equivocation freezes the pin durably: the fork's activated proof is persisted first, and a frozen store refuses export, install, accept, activation and receipt recording — every mutator rejects without touching the durable record. A ceremony is accepted only when it anchors to this store's owner/network, names this member among its recipients, and pins the current certificate; a fresh id then erases any unactivated candidate for that predecessor (C1), consumes the id, and a replayed id changes nothing. The store keeps a lock file for its whole lifetime (Busy for a second opener, `LockUnsupported` where advisory locking is unavailable — local POSIX filesystems only); open sweeps leftover temp files and refuses to open when one cannot be removed. Descendants of forked branches stay verified so a frozen store reopens frozen, not corrupt; recovery is operator-only (audit `proofs/`, abandon the directory, provision fresh). Every parent directory of the created hierarchy is fsynced. A share file is erased when the chain passes its certificate (R1), when a candidate's predecessor is covered or superseded (C2), when a fresh ceremony id arrives for its predecessor (C1), or when no record names it (S1). Every removal is committed to `state` BEFORE its unlink, so a crash is always recovered as erasure-owed, never as a missing promised share; a receipt-bearing candidate that still needs its share is the only missing-file corruption. `record_activation` is idempotent per certificate digest. Erasure is best-effort zero-overwrite + fsync + unlink: on journaling or copy-on-write filesystems the plaintext may survive in snapshots or journals and is not guaranteed erased (design v5 §5.9).
diff --git a/tinycloud-threshold/src/cert.rs b/tinycloud-threshold/src/cert.rs
index 9ee678c3..7b095e06 100644
--- a/tinycloud-threshold/src/cert.rs
+++ b/tinycloud-threshold/src/cert.rs
@@ -104,7 +104,53 @@ impl CandidateShare {
pub fn index(&self) -> MemberIndex {
self.index
}
+ /// Secret bytes: version(1) ‖ transcript hash(32) ‖ index u16be(2) ‖ scalar(32). Details: TC-2221
+ ///
+ /// The caller owns the returned buffer's erasure; it is wrapped in
+ /// [`zeroize::Zeroizing`] so a dropped copy clears itself. [`CandidateShare::from_bytes`]
+ /// restores the candidate for [`KeyShare::bind`] against the pinned certificate.
+ /// The encoding is written directly into the guarded output and the raw
+ /// scalar is guarded; no unguarded secret copy is produced.
+ pub fn to_bytes(&self) -> zeroize::Zeroizing<[u8; 67]> {
+ let mut out = zeroize::Zeroizing::new([0u8; 67]);
+ let scalar = zeroize::Zeroizing::new(self.scalar.to_bytes());
+ let (version, rest) = out.split_at_mut(1);
+ version.copy_from_slice(&[1u8]);
+ let (transcript, rest) = rest.split_at_mut(32);
+ transcript.copy_from_slice(self.transcript.as_bytes());
+ let (index, scalar_slot) = rest.split_at_mut(2);
+ index.copy_from_slice(&self.index.get().to_be_bytes());
+ scalar_slot.copy_from_slice(&*scalar);
+ out
+ }
+
+ /// Canonical decode only: version 1, index 1..=16, scalar < r and != 0, exact length. Details: TC-2221
+ pub fn from_bytes(bytes: &[u8]) -> Result {
+ if bytes.len() != 67 || bytes.first() != Some(&1) {
+ return Err(Error::InvalidEncoding);
+ }
+ let mut transcript = [0u8; 32];
+ transcript.copy_from_slice(bytes.get(1..33).ok_or(Error::InvalidEncoding)?);
+ let index = MemberIndex::new(u16::from_be_bytes([
+ *bytes.get(33).ok_or(Error::InvalidEncoding)?,
+ *bytes.get(34).ok_or(Error::InvalidEncoding)?,
+ ]))?;
+ let raw = zeroize::Zeroizing::new(
+ <[u8; 32]>::try_from(bytes.get(35..67).ok_or(Error::InvalidEncoding)?)
+ .map_err(|_| Error::InvalidEncoding)?,
+ );
+ let scalar = Scalar::from_bytes(&*raw)?;
+ if scalar.is_zero() {
+ return Err(Error::InvalidScalar);
+ }
+ Ok(Self {
+ transcript: TranscriptHash::new(transcript),
+ index,
+ scalar,
+ })
+ }
}
+
impl fmt::Debug for CandidateShare {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("CandidateShare([REDACTED])")
@@ -417,6 +463,18 @@ impl CertificateBody {
pub fn transcript_hash(&self) -> TranscriptHash {
self.transcript
}
+ /// Pinned predecessor digest, absent only at genesis. Details: TC-2221
+ pub fn previous(&self) -> Option<&CertDigest> {
+ self.previous.as_ref()
+ }
+ /// Certificate epoch. Details: TC-2221
+ pub fn epoch(&self) -> Epoch {
+ self.epoch
+ }
+ /// Re-key counter. Details: TC-2221
+ pub fn rekey(&self) -> RekeyCounter {
+ self.rekey
+ }
}
/// Raw owner-signed certificate. Details: TC-2222
diff --git a/tinycloud-threshold/src/lib.rs b/tinycloud-threshold/src/lib.rs
index 01b3b368..5d61f8d9 100644
--- a/tinycloud-threshold/src/lib.rs
+++ b/tinycloud-threshold/src/lib.rs
@@ -32,6 +32,7 @@ pub mod ids;
mod poly;
pub mod reshare;
pub mod seal;
+pub mod store;
pub use activation::{
activation_preimage, readiness_preimage, verify_activation, ReadinessReceipt, ReadyQuorum,
diff --git a/tinycloud-threshold/src/store.rs b/tinycloud-threshold/src/store.rs
new file mode 100644
index 00000000..8701d5a9
--- /dev/null
+++ b/tinycloud-threshold/src/store.rs
@@ -0,0 +1,4676 @@
+//! Durable share store: candidate persistence, activation pin and retirement.
+//! Details: TC-2221
+//!
+//! A member keeps at most one generation current, at most one candidate
+//! awaiting activation, and positive-proof records for everything it erased.
+//! Nothing in the workspace calls this module yet; the node runtime wires it
+//! in a later slice.
+//!
+//! Layout under `//`:
+//!
+//! * `lock`: advisory exclusive lock held for the store's lifetime; exactly
+//! one `ShareStore` may be open for a network at a time.
+//! * `state`: versioned record of the pinned certificate, the pending
+//! candidate, accepted ceremony ids and retired generations.
+//! * `proofs/`: the signed certificate bytes plus, once
+//! activated, the owner activation statement and readiness receipts.
+//! * `share-`: `CandidateShare::to_bytes()` for this member's
+//! share under that certificate.
+//! * `dealt/`: marker written once a ceremony id is consumed;
+//! the id is never fresh again.
+//!
+//! Every write is write-temp, fsync, rename, fsync-directory, and temporary
+//! files are uniquely named, created with `create_new`, and zeroed plus
+//! unlinked on any failure; the next open sweeps leftovers and refuses to
+//! open if one cannot be removed. The pin is the highest certificate on
+//! the single chain verified from genesis through `verify_activation` for
+//! every activated proof; a restart refuses a recorded `state` pin the
+//! proofs cannot support. Shares are erased only on positive proof — a
+//! verified activation that moves past them, a fresh ceremony for their
+//! predecessor, or no record at all — and the `state` record commits every
+//! removal before its file unlink, so a crash is always recovered as
+//! "erasure owed", never as a missing promised share.
+//!
+//! Filesystem prerequisite: the network directory must live on a
+//! filesystem where `File::try_lock` provides an exclusive advisory lock
+//! (local POSIX filesystems; network filesystems such as NFS are not
+//! supported — an implementation that cannot lock yields
+//! [`StoreError::LockUnsupported`], never `Busy`).
+//!
+//! Frozen-store recovery: activated equivocation freezes the store
+//! durably — the fork's activated proof is persisted and the freeze
+//! survives restarts. Recovery is operator-only, never automatic: the
+//! operator audits the equivocation evidence in `proofs/`, removes the
+//! network directory for the branch to abandon (the whole ``
+//! directory is self-contained), and provisions a fresh ceremony and
+//! directory for the retained chain. The store itself never selects a
+//! branch or resumes service.
+
+use std::{
+ collections::BTreeMap,
+ fs,
+ path::{Path, PathBuf},
+ sync::Mutex,
+};
+
+use sha2::{Digest, Sha256};
+use zeroize::Zeroizing;
+
+use crate::{
+ activation::{verify_activation, ReadinessReceipt, ReadyQuorum},
+ ceremony::{AuthorizedCeremony, DidSignatureVerifier, SignedStatement, TrustAnchor},
+ cert::{
+ verify_certificate, verify_successor, CandidateShare, CertificateBody, SignedCertificate,
+ VerifiedCertificate,
+ },
+ encoding::{Decoder, Encoder},
+ error::{CertError, Error},
+ ids::{CeremonyId, CertDigest, Did, MemberIndex, NetworkUrn},
+ KeyShare,
+};
+
+/// One retired generation. The predecessor it was retired against decides
+/// reachability: only a digest naming the CURRENT pin as its predecessor
+/// can ever be presented again, so records keyed to superseded pins are
+/// pruned on every pin advance. Details: TC-2221
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct RetiredGeneration {
+ /// Retired certificate digest.
+ pub digest: CertDigest,
+ /// The certificate this retired generation descended from — `None` for
+ /// a genesis root.
+ pub predecessor: Option,
+ /// Retirement sequence counter for this member.
+ pub erased_at: u64,
+}
+
+/// Hard bounds on the durable lists: the store REFUSES the operation that
+/// would cross them rather than letting `state` grow past what a reopen
+/// can parse. Details: TC-2221
+const MAX_RETIRED: usize = 65_536;
+const MAX_SEEN_CEREMONIES: usize = 65_536;
+
+/// The pending candidate record.
+#[derive(Clone, Debug)]
+struct CandidateRecord {
+ digest: CertDigest,
+ ceremony: CeremonyId,
+ predecessor: Option,
+ receipt_sent: bool,
+}
+
+/// The single ceremony admissible for installation: the most recently
+/// accepted fresh id and the predecessor it was bound to. A superseded or
+/// retired id stays in `seen_ceremonies` (history) but can never install.
+#[derive(Clone, Debug)]
+struct ActiveCeremony {
+ id: CeremonyId,
+ predecessor: Option,
+}
+
+#[derive(Clone, Debug, Default)]
+struct State {
+ pin: Option,
+ candidate: Option,
+ active_ceremony: Option,
+ seen_ceremonies: Vec,
+ retired: Vec,
+ seq: u64,
+ /// Activated equivocation froze the store; terminal and durable — it
+ /// outlives the loss of the fork evidence itself.
+ frozen: bool,
+}
+
+fn encode_state(state: &State) -> Vec {
+ let mut e = Encoder::new();
+ e.u8(3);
+ e.u8(u8::from(state.pin.is_some()));
+ if let Some(pin) = state.pin {
+ e.fixed(pin.as_bytes());
+ }
+ e.u8(u8::from(state.candidate.is_some()));
+ if let Some(candidate) = &state.candidate {
+ e.fixed(candidate.digest.as_bytes());
+ e.fixed(candidate.ceremony.as_bytes());
+ e.u8(u8::from(candidate.predecessor.is_some()));
+ if let Some(predecessor) = candidate.predecessor {
+ e.fixed(predecessor.as_bytes());
+ }
+ e.u8(u8::from(candidate.receipt_sent));
+ }
+ e.u8(u8::from(state.active_ceremony.is_some()));
+ if let Some(active) = &state.active_ceremony {
+ e.fixed(active.id.as_bytes());
+ e.u8(u8::from(active.predecessor.is_some()));
+ if let Some(predecessor) = active.predecessor {
+ e.fixed(predecessor.as_bytes());
+ }
+ }
+ // Ceremony ids and retired entries are bounded by the write caps; u32
+ // cannot overflow below memory limits.
+ e.u32(state.seen_ceremonies.len() as u32);
+ for id in &state.seen_ceremonies {
+ e.fixed(id.as_bytes());
+ }
+ e.u32(state.retired.len() as u32);
+ for retired in &state.retired {
+ e.fixed(retired.digest.as_bytes());
+ e.u8(u8::from(retired.predecessor.is_some()));
+ if let Some(predecessor) = retired.predecessor {
+ e.fixed(predecessor.as_bytes());
+ }
+ e.u64(retired.erased_at);
+ }
+ e.u64(state.seq);
+ e.u8(u8::from(state.frozen));
+ e.finish()
+}
+
+fn decode_state(bytes: &[u8]) -> Result {
+ let mut d = Decoder::new(bytes)?;
+ if d.u8()? != 3 {
+ return Err(Error::InvalidEncoding.into());
+ }
+ let pin = if d.u8()? == 1 {
+ Some(CertDigest::new(d.fixed()?))
+ } else {
+ None
+ };
+ let candidate = if d.u8()? == 1 {
+ Some(CandidateRecord {
+ digest: CertDigest::new(d.fixed()?),
+ ceremony: CeremonyId::new(d.fixed()?),
+ predecessor: if d.u8()? == 1 {
+ Some(CertDigest::new(d.fixed()?))
+ } else {
+ None
+ },
+ receipt_sent: d.u8()? == 1,
+ })
+ } else {
+ None
+ };
+ let active_ceremony = if d.u8()? == 1 {
+ Some(ActiveCeremony {
+ id: CeremonyId::new(d.fixed()?),
+ predecessor: if d.u8()? == 1 {
+ Some(CertDigest::new(d.fixed()?))
+ } else {
+ None
+ },
+ })
+ } else {
+ None
+ };
+ let seen_len = usize::try_from(d.u32()?).map_err(|_| Error::InvalidEncoding)?;
+ if seen_len > 1_000_000 {
+ return Err(Error::InvalidEncoding.into());
+ }
+ let mut seen_ceremonies = Vec::with_capacity(seen_len.min(4096));
+ for _ in 0..seen_len {
+ seen_ceremonies.push(CeremonyId::new(d.fixed()?));
+ }
+ let retired_len = usize::try_from(d.u32()?).map_err(|_| Error::InvalidEncoding)?;
+ if retired_len > 1_000_000 {
+ return Err(Error::InvalidEncoding.into());
+ }
+ let mut retired = Vec::with_capacity(retired_len.min(4096));
+ for _ in 0..retired_len {
+ retired.push(RetiredGeneration {
+ digest: CertDigest::new(d.fixed()?),
+ predecessor: if d.u8()? == 1 {
+ Some(CertDigest::new(d.fixed()?))
+ } else {
+ None
+ },
+ erased_at: d.u64()?,
+ });
+ }
+ let seq = d.u64()?;
+ let frozen = d.u8()? == 1;
+ d.finish()?;
+ Ok(State {
+ pin,
+ candidate,
+ active_ceremony,
+ seen_ceremonies,
+ retired,
+ seq,
+ frozen,
+ })
+}
+
+fn encode_signed_certificate(signed: &SignedCertificate) -> Vec {
+ let mut e = Encoder::new();
+ e.u8(1);
+ e.lp(&signed.body.encode());
+ e.lp(&signed.owner_signature);
+ e.finish()
+}
+
+fn decode_signed_certificate(bytes: &[u8]) -> Result {
+ let mut d = Decoder::new(bytes)?;
+ if d.u8()? != 1 {
+ return Err(Error::InvalidEncoding.into());
+ }
+ let body = CertificateBody::decode(d.lp(64 * 1024)?)?;
+ let owner_signature = d.lp(512)?.to_vec();
+ d.finish()?;
+ Ok(SignedCertificate {
+ body,
+ owner_signature,
+ })
+}
+
+fn encode_proof(
+ signed: &SignedCertificate,
+ activation: Option<&SignedStatement>,
+ receipts: &[ReadinessReceipt],
+) -> Vec {
+ let mut e = Encoder::new();
+ e.u8(1);
+ e.lp(&encode_signed_certificate(signed));
+ e.u8(u8::from(activation.is_some()));
+ if let Some(activation) = activation {
+ e.lp(&activation.bytes);
+ e.lp(&activation.signature);
+ // Receipts are bounded by n <= 16 members.
+ e.u16(receipts.len() as u16);
+ for receipt in receipts {
+ e.u16(receipt.index.get());
+ e.lp(&receipt.signature);
+ }
+ }
+ e.finish()
+}
+
+/// An activated proof's owner statement and member receipts. Details: TC-2221
+type ProofActivation = (SignedStatement, Vec);
+
+fn decode_proof(bytes: &[u8]) -> Result<(SignedCertificate, Option), StoreError> {
+ let mut d = Decoder::new(bytes)?;
+ if d.u8()? != 1 {
+ return Err(Error::InvalidEncoding.into());
+ }
+ let signed = decode_signed_certificate(d.lp(128 * 1024)?)?;
+ let activation = if d.u8()? == 1 {
+ let statement = SignedStatement {
+ bytes: d.lp(64 * 1024)?.to_vec(),
+ signature: d.lp(512)?.to_vec(),
+ };
+ let count = usize::from(d.u16()?);
+ if count > 16 {
+ return Err(Error::InvalidEncoding.into());
+ }
+ let mut receipts = Vec::with_capacity(count);
+ for _ in 0..count {
+ receipts.push(ReadinessReceipt {
+ index: MemberIndex::new(d.u16()?)?,
+ signature: d.lp(512)?.to_vec(),
+ });
+ }
+ Some((statement, receipts))
+ } else {
+ None
+ };
+ d.finish()?;
+ Ok((signed, activation))
+}
+
+/// Store errors. Details: TC-2221
+#[derive(Debug, thiserror::Error)]
+pub enum StoreError {
+ /// Filesystem failure.
+ #[error("share store I/O: {0}")]
+ Io(#[from] std::io::Error),
+ /// Threshold encoding/verification failure.
+ #[error("{0}")]
+ Crypto(#[from] Error),
+ /// Certificate verification failure.
+ #[error("{0}")]
+ Cert(#[from] CertError),
+ /// On-disk content failed to decode or a recorded promise lacks proof.
+ #[error("share store state is inconsistent")]
+ Corrupt,
+ /// A second candidate exists before the first was erased.
+ #[error("candidate already installed")]
+ CandidateExists,
+ /// The certificate does not extend the pin, or a genesis arrives after one.
+ #[error("activation does not extend the pinned certificate")]
+ InvalidActivation,
+ /// A durable list reached its bound; the operation is refused rather
+ /// than letting `state` grow past what a reopen can parse.
+ #[error("share store durable list is at capacity")]
+ Capacity,
+ /// Activated equivocation froze the pin; further activations are refused.
+ #[error("activated equivocation froze the pin")]
+ Equivocation,
+ /// Another `ShareStore` already holds this network's lock.
+ #[error("share store is already open for this network")]
+ Busy,
+ /// The certificate belongs to a retired generation; its share was erased
+ /// under durable proof and must never come back.
+ #[error("certificate belongs to a retired generation")]
+ Retired,
+ /// The filesystem cannot provide the exclusive open lock.
+ #[error("share store lock unsupported on this filesystem")]
+ LockUnsupported,
+}
+
+/// Result of presenting an authorized ceremony to the store. Details: TC-2221
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum AcceptOutcome {
+ /// First sight of this ceremony id: dealt marker written, id consumed,
+ /// and any unactivated candidate for its predecessor erased.
+ Fresh,
+ /// Ceremony id already consumed; nothing changed.
+ Replay,
+}
+
+/// One member's durable share store for one network. Details: TC-2221
+///
+/// `member` is this node's DID; a ceremony is accepted only when it names
+/// this member among its recipients. `open` holds an exclusive advisory
+/// lock on the network directory for the store's lifetime, so two openers
+/// — same process or sibling processes — can never cache divergent state.
+pub struct ShareStore {
+ network_dir: PathBuf,
+ anchor: TrustAnchor,
+ member: Did,
+ _lock: fs::File,
+ inner: Mutex,
+}
+
+struct Inner {
+ state: State,
+ pin: Option,
+ /// Every certificate on the verified activated chain, by digest.
+ verified: BTreeMap,
+ equivocation: bool,
+}
+
+fn write_atomic(path: &Path, bytes: &[u8]) -> Result<(), StoreError> {
+ use std::io::Write;
+ static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
+ let tag = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
+ let name = format!(
+ "{}.tmp-{}-{tag}",
+ path.file_name().and_then(|n| n.to_str()).unwrap_or("part"),
+ std::process::id()
+ );
+ let tmp = path.with_file_name(name);
+ let result = (|| {
+ let mut options = fs::OpenOptions::new();
+ options.write(true).create_new(true);
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::OpenOptionsExt;
+ options.mode(0o600);
+ }
+ let mut file = options.open(&tmp)?;
+ file.write_all(bytes)?;
+ file.sync_all()?;
+ drop(file);
+ fs::rename(&tmp, path)?;
+ if let Some(parent) = path.parent() {
+ fs::File::open(parent)?.sync_all()?;
+ }
+ Ok(())
+ })();
+ if result.is_err() {
+ // The temp may hold plaintext secret bytes: zero before unlinking.
+ let _ = erase_share_file(&tmp);
+ }
+ result
+}
+
+fn erase_share_file(path: &Path) -> Result<(), StoreError> {
+ // Never follow links or traverse directories: a store path that is not a
+ // regular file is removed as a bare name, never written through — a
+ // `*.tmp-*` symlink can otherwise make `open` zero a victim file
+ // outside the store.
+ let Ok(meta) = fs::symlink_metadata(path) else {
+ return Ok(());
+ };
+ if !meta.is_file() {
+ if meta.is_dir() {
+ fs::remove_dir(path)?;
+ } else {
+ fs::remove_file(path)?;
+ }
+ if let Some(parent) = path.parent() {
+ fs::File::open(parent)?.sync_all()?;
+ }
+ return Ok(());
+ }
+ let length = usize::try_from(meta.len()).unwrap_or(usize::MAX);
+ // Zeroing writes through the inode: a name with more than one link —
+ // or one that stops being a regular file between metadata and open —
+ // must be dropped as a bare name, never overwritten. O_NOFOLLOW keeps
+ // that check atomic with the open.
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::{MetadataExt, OpenOptionsExt};
+ if meta.nlink() > 1 {
+ fs::remove_file(path)?;
+ if let Some(parent) = path.parent() {
+ fs::File::open(parent)?.sync_all()?;
+ }
+ return Ok(());
+ }
+ use std::io::Write;
+ const O_NOFOLLOW: i32 = 0x20000;
+ let mut file = fs::OpenOptions::new()
+ .write(true)
+ .custom_flags(O_NOFOLLOW)
+ .open(path)?;
+ file.write_all(&Zeroizing::new(vec![0u8; length]))?;
+ file.sync_all()?;
+ }
+ #[cfg(not(unix))]
+ {
+ use std::io::Write;
+ let mut file = fs::OpenOptions::new().write(true).open(path)?;
+ file.write_all(&Zeroizing::new(vec![0u8; length]))?;
+ file.sync_all()?;
+ }
+ fs::remove_file(path)?;
+ if let Some(parent) = path.parent() {
+ fs::File::open(parent)?.sync_all()?;
+ }
+ Ok(())
+}
+
+fn share_path(dir: &Path, digest: &CertDigest) -> PathBuf {
+ dir.join(format!("share-{}", hex(digest.as_bytes())))
+}
+
+fn proof_path(dir: &Path, digest: &CertDigest) -> PathBuf {
+ dir.join("proofs").join(hex(digest.as_bytes()))
+}
+
+/// Record a retirement durably before its unlink: the entry carries the
+/// certificate the retired generation descended from, which is the only
+/// pin context under which the digest can ever be presented again.
+/// Details: TC-2221
+fn record_retired(state: &mut State, digest: CertDigest, predecessor: Option) {
+ state.seq = state.seq.saturating_add(1);
+ state.retired.push(RetiredGeneration {
+ digest,
+ predecessor,
+ erased_at: state.seq,
+ });
+}
+
+/// Recovery-path append: `open` cannot refuse to open without bricking the
+/// store, so it compacts dead entries first and then records — the cap
+/// still binds every operational mutator, while a recovery write only
+/// closes the gap the crash left. Details: TC-2221
+fn recover_retired(
+ state: &mut State,
+ digest: CertDigest,
+ predecessor: Option,
+ pin: Option,
+) {
+ if state.retired.len() >= MAX_RETIRED {
+ state.retired.retain(|r| r.predecessor == pin);
+ }
+ record_retired(state, digest, predecessor);
+}
+
+/// Compact the retirement list only when it nears the bound: an entry is
+/// reachable — and therefore must be kept — only while the pin equals its
+/// recorded predecessor; dead entries are dropped first. Reachable entries
+/// are never dropped: if the list still overflows, the operation must be
+/// refused instead of resurrecting a retired share. Details: TC-2221
+fn compact_retired(state: &mut State, pin: Option) -> bool {
+ if state.retired.len() <= MAX_RETIRED {
+ return true;
+ }
+ state.retired.retain(|r| r.predecessor == pin);
+ state.retired.len() <= MAX_RETIRED
+}
+
+fn hex(bytes: &[u8]) -> String {
+ const HEX: &[u8; 16] = b"0123456789abcdef";
+ let mut out = String::with_capacity(bytes.len().saturating_mul(2));
+ for byte in bytes {
+ if let Some(hi) = HEX.get(usize::from(byte >> 4)) {
+ out.push(*hi as char);
+ }
+ if let Some(lo) = HEX.get(usize::from(byte & 0x0f)) {
+ out.push(*lo as char);
+ }
+ }
+ out
+}
+
+fn hex_val(byte: u8) -> Option {
+ match byte {
+ b'0'..=b'9' => byte.checked_sub(b'0'),
+ b'a'..=b'f' => byte.checked_sub(b'a').and_then(|v| v.checked_add(10)),
+ _ => None,
+ }
+}
+
+fn decode_hex(text: &str) -> Option<[u8; 32]> {
+ if text.len() != 64 {
+ return None;
+ }
+ let mut out = [0u8; 32];
+ let bytes = text.as_bytes();
+ for (i, slot) in out.iter_mut().enumerate() {
+ let hi = hex_val(*bytes.get(i.saturating_mul(2))?)?;
+ let lo = hex_val(*bytes.get(i.saturating_mul(2).saturating_add(1))?)?;
+ *slot = (hi << 4) | lo;
+ }
+ Some(out)
+}
+
+fn store_dir(dir: &Path, network: &NetworkUrn) -> PathBuf {
+ let digest = Sha256::digest(network.as_bytes());
+ let name = hex(digest.get(..16).unwrap_or_default());
+ dir.join(name)
+}
+
+/// Zero and unlink any leftover `*.tmp-*` or `*.tmp` write-temp file under
+/// the network directory. Runs inside `open` after the lock is taken; a
+/// temp that cannot be removed (it may hold plaintext secret bytes) refuses
+/// the open rather than surviving silently.
+fn sweep_temps(network_dir: &Path) -> Result<(), StoreError> {
+ for sub in [
+ network_dir.to_path_buf(),
+ network_dir.join("proofs"),
+ network_dir.join("dealt"),
+ ] {
+ for entry in fs::read_dir(&sub)? {
+ let entry = entry?;
+ let name = entry.file_name();
+ let Some(name) = name.to_str() else { continue };
+ if name.ends_with(".tmp") || name.contains(".tmp-") {
+ erase_share_file(&entry.path())?;
+ }
+ }
+ }
+ Ok(())
+}
+
+impl ShareStore {
+ /// Open or create the member's share directory and converge it. Details: TC-2221
+ ///
+ /// Convergence order: rebuild the verified activated chain from `proofs/`,
+ /// refuse a recorded pin the chain cannot prove, then apply the erase
+ /// rules — R1 (superseded generations), C2 (losing candidates), S1
+ /// (files named in no record) and orphan dealt markers.
+ pub fn open(
+ dir: &Path,
+ network: &NetworkUrn,
+ anchor: TrustAnchor,
+ member: Did,
+ v: &impl DidSignatureVerifier,
+ ) -> Result {
+ let network_dir = store_dir(dir, network);
+ // Fsync only what this call created — network_dir, proofs, dealt,
+ // and the first pre-existing parent that must record them. Ancestors
+ // above that belong to the caller; an execute-only one must not be
+ // required readable.
+ let existed = network_dir.exists();
+ fs::create_dir_all(network_dir.join("proofs"))?;
+ fs::create_dir_all(network_dir.join("dealt"))?;
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::PermissionsExt;
+ fs::set_permissions(&network_dir, fs::Permissions::from_mode(0o700))?;
+ }
+ fs::File::open(&network_dir)?.sync_all()?;
+ fs::File::open(network_dir.join("proofs"))?.sync_all()?;
+ fs::File::open(network_dir.join("dealt"))?.sync_all()?;
+ if !existed {
+ if let Some(parent) = network_dir.parent() {
+ fs::File::open(parent)?.sync_all()?;
+ }
+ }
+
+ // One opener per network directory, held for the store's lifetime.
+ let lock_path = network_dir.join("lock");
+ let lock_file = {
+ let mut options = fs::OpenOptions::new();
+ options.write(true).create(true);
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::OpenOptionsExt;
+ options.mode(0o600);
+ }
+ options.open(&lock_path)?
+ };
+ // A held contender is Busy; an implementation that cannot lock at
+ // all (an unsupported filesystem) is a distinct refusal: the store
+ // will not run where the single-opener guarantee cannot be made.
+ lock_file.try_lock().map_err(|e| match e {
+ std::fs::TryLockError::WouldBlock => StoreError::Busy,
+ std::fs::TryLockError::Error(_) => StoreError::LockUnsupported,
+ })?;
+
+ sweep_temps(&network_dir)?;
+
+ let mut state = match fs::read(network_dir.join("state")) {
+ Ok(bytes) => decode_state(&bytes)?,
+ Err(e) if e.kind() == std::io::ErrorKind::NotFound => State::default(),
+ Err(e) => return Err(e.into()),
+ };
+ // The decoded record before any in-memory bookkeeping: if a freeze
+ // is discovered the persist write must carry only the flag flip —
+ // never incidental mutations like orphan dealt-marker recording.
+ let decoded_state = state.clone();
+ let mut state_dirty = false;
+
+ // Orphan dealt markers: a marker whose id was never recorded consumed
+ // the id anyway, so a retried authorization is never "new".
+ for entry in fs::read_dir(network_dir.join("dealt"))? {
+ let entry = entry?;
+ let name = entry.file_name();
+ let Some(name) = name.to_str() else { continue };
+ if name == "state" || name.len() != 64 {
+ continue;
+ }
+ let Some(id_bytes) = decode_hex(name) else {
+ continue;
+ };
+ let id = CeremonyId::new(id_bytes);
+ if !state.seen_ceremonies.contains(&id) {
+ state.seen_ceremonies.push(id);
+ state_dirty = true;
+ }
+ }
+
+ // Load every proof; a malformed file is corruption, never skipped.
+ let mut proofs: BTreeMap = BTreeMap::new();
+ let mut activations: BTreeMap)> =
+ BTreeMap::new();
+ for entry in fs::read_dir(network_dir.join("proofs"))? {
+ let entry = entry?;
+ let name = entry.file_name();
+ let Some(name) = name.to_str() else { continue };
+ if name.ends_with(".tmp") || name.contains(".tmp-") {
+ continue;
+ }
+ let (signed, activation) = decode_proof(&fs::read(entry.path())?)?;
+ let digest = signed.body.digest();
+ if hex(digest.as_bytes()) != name {
+ return Err(StoreError::Corrupt);
+ }
+ proofs.insert(digest, signed);
+ if let Some((statement, receipts)) = activation {
+ activations.insert(digest, (statement, receipts));
+ }
+ }
+
+ // Build the activated chain from genesis forward. Two activated
+ // successors of one certificate (or two activated geneses) freeze the
+ // pin at the fork.
+ let mut verified: BTreeMap = BTreeMap::new();
+ let mut chain: Vec = Vec::new();
+ // A durably-frozen store stays frozen even when the fork evidence
+ // itself is later lost; the freeze is terminal by record, not by
+ // recomputation.
+ let mut equivocation = state.frozen;
+ // Every flagged activation is verified before it can extend the pin
+ // or count toward a fork; an activation flag alone proves nothing.
+ let mut geneses: Vec = Vec::new();
+ for signed in proofs.values() {
+ let Some((statement, receipts)) = activations.get(&signed.body.digest()) else {
+ continue;
+ };
+ if signed.body.previous().is_some() {
+ continue;
+ }
+ let verified_cert = verify_certificate(&anchor, signed, v)?;
+ verify_activation(
+ &verified_cert,
+ receipts,
+ ReadyQuorum::AllHolders,
+ statement,
+ v,
+ )?;
+ geneses.push(verified_cert);
+ }
+ geneses.sort_by(|a, b| a.digest().as_bytes().cmp(b.digest().as_bytes()));
+ if geneses.len() > 1 {
+ equivocation = true;
+ }
+ for genesis in &geneses {
+ verified.insert(genesis.digest(), genesis.clone());
+ }
+ if let Some(first) = geneses.into_iter().next() {
+ let mut current = first;
+ chain.push(current.digest());
+ verified.insert(current.digest(), current.clone());
+ loop {
+ let mut successors: Vec = Vec::new();
+ for signed in proofs.values() {
+ if signed.body.previous() != Some(¤t.digest()) {
+ continue;
+ }
+ let Some((statement, receipts)) = activations.get(&signed.body.digest()) else {
+ continue;
+ };
+ let next = verify_successor(¤t, signed, v)?;
+ verify_activation(&next, receipts, ReadyQuorum::AllHolders, statement, v)?;
+ successors.push(next);
+ }
+ successors.sort_by(|a, b| a.digest().as_bytes().cmp(b.digest().as_bytes()));
+ if successors.len() > 1 {
+ // The pin stops advancing at the fork; every verified fork
+ // child is retained so a recorded pin among them is
+ // proven (not corruption) and their shares retire.
+ equivocation = true;
+ for next in successors {
+ verified.insert(next.digest(), next);
+ }
+ break;
+ }
+ let Some(next) = successors.into_iter().next() else {
+ break;
+ };
+ verified.insert(next.digest(), next.clone());
+ chain.push(next.digest());
+ current = next;
+ }
+ }
+ // Fixpoint: descendants of ANY verified activated certificate —
+ // including descendants of a fork's losing branch — are verified
+ // activated too. They cannot move the pin (the fork already froze
+ // it) but they keep their recorded pin proven and their shares
+ // retire by the same rules instead of reading as corruption.
+ loop {
+ let mut added = Vec::new();
+ for signed in proofs.values() {
+ let digest = signed.body.digest();
+ if verified.contains_key(&digest) {
+ continue;
+ }
+ let Some(parent) = signed.body.previous().and_then(|p| verified.get(p)) else {
+ continue;
+ };
+ let Some((statement, receipts)) = activations.get(&digest) else {
+ continue;
+ };
+ let next = verify_successor(parent, signed, v)?;
+ verify_activation(&next, receipts, ReadyQuorum::AllHolders, statement, v)?;
+ added.push(next);
+ }
+ if added.is_empty() {
+ break;
+ }
+ for next in added {
+ verified.insert(next.digest(), next);
+ }
+ }
+
+ let computed_pin = chain.last().copied();
+ let pin = computed_pin.and_then(|d| verified.get(&d).cloned());
+
+ // A recorded pin must be a verified activated certificate: either on
+ // the surviving chain or an equivocated branch descendant. Anything
+ // else is corruption. A durably frozen store skips the check: the
+ // freeze is terminal and the pin's own proof may legitimately be
+ // the evidence that was lost.
+ if !state.frozen {
+ if let Some(recorded) = state.pin {
+ if !verified.contains_key(&recorded) {
+ return Err(StoreError::Corrupt);
+ }
+ }
+ }
+
+ // The chain tip being a retired digest is a durable contradiction:
+ // it means the member's usable share is precisely the one the
+ // record erased (a never-activated candidate that later gained an
+ // activated proof on disk — peer sync, operator restore). The live
+ // path refuses the same certificate as `Retired`; reopening freezes
+ // rather than letting R1 erase the still-live share underneath it.
+ // Superseded pins mid-chain are normal history: every outgoing pin
+ // keeps its proof and its retirement record.
+ if !equivocation
+ && computed_pin.is_some_and(|tip| state.retired.iter().any(|r| r.digest == tip))
+ {
+ equivocation = true;
+ }
+ // Persist a newly-discovered freeze before honoring it: the freeze
+ // is terminal, so it must outlive the evidence that produced it.
+ if equivocation && !state.frozen {
+ state = State {
+ frozen: true,
+ ..decoded_state
+ };
+ write_atomic(&network_dir.join("state"), &encode_state(&state))?;
+ }
+
+ // Candidate reconciliation and R1/C2/S1 erasure run ONLY on an
+ // unequivocal chain. Once activated equivocation is on disk the
+ // store freezes in place: the recorded verified pin stands, every
+ // share file is kept (losing nothing is the whole point of the
+ // freeze), and only the operator's recovery path rewrites it.
+ if !equivocation {
+ // The pin's share is this member's live key material: durable
+ // proof says it was installed and never retired, so its absence
+ // is disk damage the store must not guess past.
+ if let Some(pin) = computed_pin {
+ let holds_member = verified
+ .get(&pin)
+ .map(|c| c.body().members().iter().any(|m| m.node == member))
+ .unwrap_or(false);
+ if holds_member && !share_path(&network_dir, &pin).exists() {
+ return Err(StoreError::Corrupt);
+ }
+ }
+
+ // Candidate reconciliation. Chain membership and C2 losses are
+ // judged BEFORE the missing-file check: a candidate already
+ // retired by durable proof is settled even when its share is
+ // gone, while a still-live receipt-bearing candidate that lost
+ // its share is the only case that is truly corrupt.
+ let mut owed_erasures: Vec = Vec::new();
+ if let Some(candidate) = state.candidate.clone() {
+ let share = share_path(&network_dir, &candidate.digest);
+ let proof = proof_path(&network_dir, &candidate.digest);
+ if chain.contains(&candidate.digest) {
+ // Already activated before the crash; the chain is the
+ // record.
+ state.candidate = None;
+ state_dirty = true;
+ } else {
+ // C2: the candidate lost if its predecessor is covered
+ // by the pin (any pin for a genesis predecessor) or the
+ // chain moved past it through another certificate.
+ let lost = match candidate.predecessor {
+ None => computed_pin.is_some(),
+ Some(p) => chain
+ .iter()
+ .position(|d| *d == p)
+ .map_or(computed_pin.is_some(), |i| {
+ computed_pin != Some(p) || i.saturating_add(1) < chain.len()
+ }),
+ };
+ if lost {
+ // Commit the retirement first; the unlink is owed
+ // work a crash simply re-discovers as S1.
+ recover_retired(
+ &mut state,
+ candidate.digest,
+ candidate.predecessor,
+ computed_pin,
+ );
+ state.candidate = None;
+ state.pin = computed_pin;
+ write_atomic(&network_dir.join("state"), &encode_state(&state))?;
+ owed_erasures.push(share);
+ } else if !share.exists() || !proof.exists() {
+ if candidate.receipt_sent {
+ // A receipt was signed over a durable install;
+ // a missing file means the member promised what
+ // it cannot serve.
+ return Err(StoreError::Corrupt);
+ }
+ state.candidate = None;
+ state.pin = computed_pin;
+ write_atomic(&network_dir.join("state"), &encode_state(&state))?;
+ owed_erasures.push(share);
+ }
+ }
+ }
+
+ // R1 and S1 over share files. Every retirement is committed to
+ // `state` before its unlink; files left by a crash re-enter here
+ // as owed work on the next open.
+ for entry in fs::read_dir(&network_dir)? {
+ let entry = entry?;
+ let name = entry.file_name();
+ let Some(name) = name.to_str() else { continue };
+ let Some(hex_digest) = name.strip_prefix("share-") else {
+ continue;
+ };
+ let Some(digest_bytes) = decode_hex(hex_digest) else {
+ // An unnamed partial write (including .tmp leftovers)
+ // can never have been recorded; S1 erases it.
+ erase_share_file(&entry.path())?;
+ continue;
+ };
+ let digest = CertDigest::new(digest_bytes);
+ let is_pin = computed_pin == Some(digest);
+ let is_candidate = state.candidate.as_ref().is_some_and(|c| c.digest == digest);
+ if is_pin || is_candidate {
+ continue;
+ }
+ if verified.contains_key(&digest) {
+ // Superseded activated generation (R1).
+ if !state.retired.iter().any(|r| r.digest == digest) {
+ let predecessor = proofs
+ .get(&digest)
+ .ok_or(StoreError::Corrupt)?
+ .body
+ .previous()
+ .copied();
+ recover_retired(&mut state, digest, predecessor, computed_pin);
+ state.pin = computed_pin;
+ write_atomic(&network_dir.join("state"), &encode_state(&state))?;
+ }
+ owed_erasures.push(entry.path());
+ } else {
+ // Outside the activated chain and named in no record:
+ // S1 erases unconditionally (the pin's existence changes
+ // nothing — such a file never had a durable install
+ // record).
+ erase_share_file(&entry.path())?;
+ }
+ }
+
+ if state.pin != computed_pin || state_dirty {
+ state.pin = computed_pin;
+ write_atomic(&network_dir.join("state"), &encode_state(&state))?;
+ }
+
+ // Owed unlinks run only after every corresponding `state`
+ // commit.
+ for path in &owed_erasures {
+ erase_share_file(path)?;
+ }
+
+ if state.pin != computed_pin || state_dirty {
+ state.pin = computed_pin;
+ write_atomic(&network_dir.join("state"), &encode_state(&state))?;
+ }
+ }
+
+ // The in-memory pin: normally the computed chain tip; on
+ // equivocation the recorded verified pin (a branch descendant is
+ // proven, not corruption), so a reopen is the same freeze the live
+ // store hit.
+ let pin = if equivocation {
+ state
+ .pin
+ .and_then(|d| verified.get(&d).cloned())
+ .or_else(|| computed_pin.and_then(|d| verified.get(&d).cloned()))
+ } else {
+ pin
+ };
+
+ Ok(Self {
+ network_dir,
+ anchor,
+ member,
+ _lock: lock_file,
+ inner: Mutex::new(Inner {
+ state,
+ pin,
+ verified,
+ equivocation,
+ }),
+ })
+ }
+
+ /// The pinned certificate, if any activation chain exists. Details: TC-2221
+ pub fn pinned(&self) -> Option {
+ self.inner.lock().ok()?.pin.clone()
+ }
+
+ /// Whether activated equivocation froze the pin. Details: TC-2221
+ pub fn equivocation(&self) -> bool {
+ self.inner.lock().map(|i| i.equivocation).unwrap_or(true)
+ }
+
+ /// Retired generations recorded so far. Details: TC-2221
+ pub fn retired(&self) -> Vec {
+ self.inner
+ .lock()
+ .map(|i| i.state.retired.clone())
+ .unwrap_or_default()
+ }
+
+ /// The bound share for the pinned certificate, or none when this member
+ /// holds no share for it. Details: TC-2221
+ ///
+ /// Returns `Equivocation` when a disk-discovered fork froze the pin: a
+ /// frozen store never exports share material.
+ pub fn current_share(&self) -> Result