Skip to main content

qualia_client_core/
accountability_store.rs

1//! **Persistence for the accountability fabric** — the on-disk home for the tamper-evident
2//! [`AccountabilityLedger`], the revocable [`ConsentCredential`]s, and the durable [`ConductRecord`]s
3//! (ADR 0011 D2/D4/D5). It turns the tested domain models into something the desktop can actually use.
4//!
5//! Design: one small JSON sidecar file (`wellfair/accountability.json`), loaded/saved whole, matching the
6//! sibling-store convention (`personal_profile::EmergencyContactStore`). A person's own accountability set is
7//! small, and whole-file rewrite is the correct shape for a store whose records **mutate** (a credential is
8//! *revoked* — the wrapped key destroyed in place — so append-only JSONL would not do).
9//!
10//! **Every accountability-relevant act is written into the signed hash-chained ledger**, not only the
11//! convenience indexes: granting a credential logs a `"consent_granted"` entry, revoking logs
12//! `"consent_revoked"`, and recording conduct logs a `"conduct"` entry carrying the record. So the ledger is
13//! the authoritative tamper-evident spine (a betrayer cannot quietly drop the inconvenient act — [`verify`]
14//! catches it), and [`AccountabilityState::credentials`] / [`AccountabilityState::conduct`] are fast views
15//! over it.
16//!
17//! Scope: this is *tamper-evidence + local durability*. Anti-deletion **durability across parties** (so no one
18//! can destroy the only copy) is the commons-replication layer (swarm/WebTorrent; coordinate), and the two
19//! compose — replicate this file, and any pruned/rewritten copy is provably tampered. Real envelope
20//! encryption of the wrapped key is the vault's job (deferred); here the wrapped key is carried as opaque
21//! bytes, exactly as the model intends.
22//!
23//! [`verify`]: AccountabilityLedger::verify
24
25use std::fs;
26use std::path::{Path, PathBuf};
27
28use ed25519_dalek::{Signer, SigningKey};
29use serde::{Deserialize, Serialize};
30use sha2::{Digest, Sha256};
31
32use crate::accountability_ledger::{AccountabilityLedger, LedgerEntry, LedgerTamper};
33use crate::consent_credential::{
34    audit_trail_for_credential, Attestation, ConductRecord, ConsentCredential,
35    EncryptedCommonsPayload, PayloadCommitment,
36};
37use crate::dead_mans_switch::{DeadMansSwitch, Disposition, PartyAttestation};
38use crate::disclosure_trace::{
39    actors_with_access, disclosure_chain, trace_leak, DisclosureEvent, DisclosureFingerprint,
40    TransparencyCc,
41};
42use crate::incapacity_switch::IncapacitySwitch;
43
44/// Sidecar file, under the same `wellfair/` prefix as the other host stores.
45pub const STORE_FILE: &str = "wellfair/accountability.json";
46
47/// Ledger record kinds this store writes (the tamper-evident spine's vocabulary).
48pub const KIND_CONSENT_GRANTED: &str = "consent_granted";
49pub const KIND_CONSENT_REVOKED: &str = "consent_revoked";
50pub const KIND_CONDUCT: &str = "conduct";
51pub const KIND_DEAD_MANS_ARMED: &str = "dead_mans_armed";
52pub const KIND_DEAD_MANS_ALIVE: &str = "dead_mans_alive";
53pub const KIND_DEAD_MANS_ATTESTED: &str = "dead_mans_attested";
54pub const KIND_DEAD_MANS_ENACTED: &str = "dead_mans_enacted";
55pub const KIND_INCAPACITY_ARMED: &str = "incapacity_armed";
56pub const KIND_INCAPACITY_ACTIVATED: &str = "incapacity_activated";
57pub const KIND_INCAPACITY_REVERSED: &str = "incapacity_reversed";
58pub const KIND_TRANSPARENCY_CC: &str = "transparency_cc";
59pub const KIND_DISCLOSURE: &str = "disclosure";
60
61/// A persisted dead-man switch together with the party attestations accumulated toward its trigger. The
62/// [`DeadMansSwitch`] domain type carries no attestations (they are passed to `enact`); the store holds them
63/// so the gamified trigger can accumulate across sessions (in the real model, on the friends' devices).
64#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
65pub struct DeadMansSwitchRecord {
66    pub switch: DeadMansSwitch,
67    #[serde(default)]
68    pub attestations: Vec<PartyAttestation>,
69}
70
71/// The whole persisted accountability set.
72#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
73pub struct AccountabilityState {
74    /// The authoritative, signed, hash-chained record of every act (the tamper-evidence spine).
75    pub ledger: AccountabilityLedger,
76    /// The consent credentials granted (mutated in place on revoke — the wrapped key is destroyed).
77    pub credentials: Vec<ConsentCredential>,
78    /// The durable conduct trail (survives credential revocation; also mirrored into the ledger).
79    pub conduct: Vec<ConductRecord>,
80    /// The **envelope-encrypted** commons payloads (ciphertext + content-address commitment), keyed by
81    /// commitment via a credential's `payload_commitment`. Only ciphertext lives here — the DEK is sealed
82    /// inside each credential's `wrapped_key`, never stored in the clear.
83    #[serde(default)]
84    pub payloads: Vec<EncryptedCommonsPayload>,
85    /// Armed **dead-man switches** (post-death disposition), each with its accumulated attestations.
86    #[serde(default)]
87    pub dead_mans_switches: Vec<DeadMansSwitchRecord>,
88    /// Armed **incapacity switches** (advocate activation on validated, reversible incapacity).
89    #[serde(default)]
90    pub incapacity_switches: Vec<IncapacitySwitch>,
91    /// **Transparency cc's** — durable "I informed authority X on date Y" protective records.
92    #[serde(default)]
93    pub disclosure_ccs: Vec<TransparencyCc>,
94    /// **Disclosure events** — the durable, attributable access/onward-share trail (who saw what, via whom).
95    #[serde(default)]
96    pub disclosure_events: Vec<DisclosureEvent>,
97}
98
99/// On-disk store for the accountability fabric.
100pub struct AccountabilityStore {
101    path: PathBuf,
102}
103
104impl AccountabilityStore {
105    /// Open (or prepare to create) the store under `storage_root`.
106    pub fn open(storage_root: impl AsRef<Path>) -> std::io::Result<Self> {
107        let path = storage_root.as_ref().join(STORE_FILE);
108        if let Some(parent) = path.parent() {
109            fs::create_dir_all(parent)?;
110        }
111        Ok(Self { path })
112    }
113
114    /// Load the whole set (empty default if the file does not yet exist).
115    pub fn load(&self) -> std::io::Result<AccountabilityState> {
116        match fs::read(&self.path) {
117            Ok(bytes) => {
118                serde_json::from_slice(&bytes).map_err(|e| std::io::Error::other(e.to_string()))
119            }
120            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
121                Ok(AccountabilityState::default())
122            }
123            Err(e) => Err(e),
124        }
125    }
126
127    /// Persist the whole set (write-to-temp then rename, so a crash can't leave a half-written chain).
128    pub fn save(&self, state: &AccountabilityState) -> std::io::Result<()> {
129        let bytes =
130            serde_json::to_vec_pretty(state).map_err(|e| std::io::Error::other(e.to_string()))?;
131        let tmp = self.path.with_extension("json.tmp");
132        fs::write(&tmp, &bytes)?;
133        fs::rename(&tmp, &self.path)?;
134        Ok(())
135    }
136
137    /// Append a raw record to the tamper-evident ledger, signed by `signer`, and persist. Returns the entry.
138    pub fn append_ledger(
139        &self,
140        kind: &str,
141        payload_json: &str,
142        signer: &SigningKey,
143        time_unix: u64,
144    ) -> std::io::Result<LedgerEntry> {
145        let mut state = self.load()?;
146        let entry = state
147            .ledger
148            .append(kind, payload_json, signer, time_unix)
149            .clone();
150        self.save(&state)?;
151        Ok(entry)
152    }
153
154    /// Verify the whole ledger chain. `Ok(None)` = intact; `Ok(Some(tamper))` = a detected, named tamper.
155    pub fn verify_ledger(&self) -> std::io::Result<Result<(), LedgerTamper>> {
156        Ok(self.load()?.ledger.verify())
157    }
158
159    /// The most-recent ledger entries (newest first), capped to `limit`.
160    pub fn ledger_entries(&self, limit: usize) -> std::io::Result<Vec<LedgerEntry>> {
161        let mut entries = self.load()?.ledger.entries().to_vec();
162        entries.reverse();
163        entries.truncate(limit);
164        Ok(entries)
165    }
166
167    /// **Grant a consent credential** and log it into the ledger. The credential is stored; a
168    /// `"consent_granted"` entry (subject → agent, scope, purpose) enters the signed chain.
169    pub fn grant_credential(
170        &self,
171        cred: ConsentCredential,
172        signer: &SigningKey,
173        time_unix: u64,
174    ) -> std::io::Result<ConsentCredential> {
175        let mut state = self.load()?;
176        let note = serde_json::json!({
177            "credential_id": cred.id,
178            "subject": cred.subject_did,
179            "agent": cred.agent_did,
180            "scope": cred.scope,
181            "purpose": cred.purpose,
182            "commitment": hex::encode(cred.payload_commitment),
183        })
184        .to_string();
185        state
186            .ledger
187            .append(KIND_CONSENT_GRANTED, &note, signer, time_unix);
188        state.credentials.push(cred.clone());
189        self.save(&state)?;
190        Ok(cred)
191    }
192
193    /// **Revoke a consent credential** — crypto-enforced (the wrapped key is destroyed in
194    /// [`ConsentCredential::revoke`]) — and log a `"consent_revoked"` entry. Returns `true` if a live
195    /// credential was revoked. The credential row and every conduct record under it **persist**: revoking
196    /// consent removes access, never accountability.
197    pub fn revoke_credential(
198        &self,
199        credential_id: &str,
200        signer: &SigningKey,
201        time_unix: u64,
202    ) -> std::io::Result<bool> {
203        let mut state = self.load()?;
204        let Some(cred) = state.credentials.iter_mut().find(|c| c.id == credential_id) else {
205            return Ok(false);
206        };
207        let was_active = cred.is_active(time_unix);
208        cred.revoke(time_unix);
209        let note = serde_json::json!({
210            "credential_id": credential_id,
211            "revoked_unix": time_unix,
212        })
213        .to_string();
214        state
215            .ledger
216            .append(KIND_CONSENT_REVOKED, &note, signer, time_unix);
217        self.save(&state)?;
218        Ok(was_active)
219    }
220
221    /// All stored credentials (active and revoked — the revoked ones remain as the audit anchor).
222    pub fn list_credentials(&self) -> std::io::Result<Vec<ConsentCredential>> {
223        Ok(self.load()?.credentials)
224    }
225
226    /// The still-live **wrapped DEK** held by `agent_did`'s credential for `commitment` (e.g. the owner's own
227    /// credential), so the DEK can be recovered and re-sealed on enactment. `None` if no such active credential.
228    pub fn wrapped_key_for(
229        &self,
230        commitment: &PayloadCommitment,
231        agent_did: &str,
232        now_unix: u64,
233    ) -> std::io::Result<Option<Vec<u8>>> {
234        let state = self.load()?;
235        Ok(state
236            .credentials
237            .iter()
238            .find(|c| &c.payload_commitment == commitment && c.agent_did == agent_did)
239            .and_then(|c| c.payload_key(now_unix).map(|k| k.to_vec())))
240    }
241
242    /// **Record an agent's conduct** under a credential — signed by `signer` (an
243    /// [`Attestation::Signature`]) — into both the durable conduct trail and the tamper-evident ledger. The
244    /// record binds to the payload **commitment** (not the payload), so it proves *what* was acted on without
245    /// holding the datum, and it **survives** the credential's revocation.
246    pub fn record_conduct(
247        &self,
248        agent_did: impl Into<String>,
249        credential_id: impl Into<String>,
250        action: impl Into<String>,
251        reason: impl Into<String>,
252        commitment: PayloadCommitment,
253        signer: &SigningKey,
254        time_unix: u64,
255    ) -> std::io::Result<ConductRecord> {
256        let agent_did = agent_did.into();
257        let credential_id = credential_id.into();
258        let action = action.into();
259        let reason = reason.into();
260
261        // The signature is over the bound content — attributable + court-auditable.
262        let bound = content_signing_bytes(
263            &agent_did,
264            &credential_id,
265            &action,
266            &reason,
267            &commitment,
268            time_unix,
269        );
270        let sig = signer.sign(&bound);
271        let id = conduct_id(&agent_did, &credential_id, &action, time_unix);
272
273        let record = ConductRecord {
274            id,
275            agent_did,
276            credential_id,
277            action,
278            reason,
279            time_unix,
280            payload_commitment: commitment,
281            attestation: Attestation::Signature {
282                alg: "ed25519".into(),
283                sig_hex: hex::encode(sig.to_bytes()),
284            },
285        };
286
287        let mut state = self.load()?;
288        let payload =
289            serde_json::to_string(&record).map_err(|e| std::io::Error::other(e.to_string()))?;
290        state
291            .ledger
292            .append(KIND_CONDUCT, &payload, signer, time_unix);
293        state.conduct.push(record.clone());
294        self.save(&state)?;
295        Ok(record)
296    }
297
298    /// The **audit view** — every conduct record taken under one credential, in order. Exactly the records
299    /// that survive that credential's revocation (the accountability the person cannot erase and the agent
300    /// cannot withhold).
301    pub fn audit_trail(&self, credential_id: &str) -> std::io::Result<Vec<ConductRecord>> {
302        let state = self.load()?;
303        Ok(audit_trail_for_credential(&state.conduct, credential_id)
304            .into_iter()
305            .cloned()
306            .collect())
307    }
308
309    /// **Seal a plaintext payload and grant a consent credential over it** — the *real envelope-encryption*
310    /// path (as opposed to [`grant_credential`], which takes an already-wrapped key). Generates a random DEK,
311    /// AEAD-encrypts the plaintext, content-addresses the ciphertext, seals the DEK to `recipient_public`
312    /// (the credential's real `wrapped_key`), stores the ciphertext in the commons, grants the credential,
313    /// and logs `consent_granted`. Returns the granted credential. Nothing is stored in the clear: the
314    /// plaintext becomes ciphertext, and the DEK survives only sealed inside the credential.
315    ///
316    /// [`grant_credential`]: AccountabilityStore::grant_credential
317    #[cfg(not(target_arch = "wasm32"))]
318    #[allow(clippy::too_many_arguments)]
319    pub fn seal_and_grant_credential(
320        &self,
321        credential_id: impl Into<String>,
322        subject_did: impl Into<String>,
323        agent_did: impl Into<String>,
324        scope: impl Into<String>,
325        purpose: impl Into<String>,
326        plaintext: &[u8],
327        recipient_public: &[u8; 32],
328        storers: Vec<String>,
329        expiry_unix: Option<u64>,
330        signer: &SigningKey,
331        time_unix: u64,
332    ) -> std::io::Result<ConsentCredential> {
333        use crate::envelope_encryption::{seal_payload, wrap_dek_to};
334        let (payload, dek) = seal_payload(plaintext, storers).map_err(std::io::Error::other)?;
335        let wrapped = wrap_dek_to(recipient_public, &dek).map_err(std::io::Error::other)?;
336        let commitment = payload.commitment;
337        let cred = ConsentCredential::grant(
338            credential_id,
339            subject_did,
340            agent_did,
341            scope,
342            purpose,
343            commitment,
344            wrapped,
345            time_unix,
346            expiry_unix,
347        );
348
349        let mut state = self.load()?;
350        state.payloads.push(payload);
351        let note = serde_json::json!({
352            "credential_id": cred.id,
353            "subject": cred.subject_did,
354            "agent": cred.agent_did,
355            "scope": cred.scope,
356            "purpose": cred.purpose,
357            "commitment": hex::encode(commitment),
358            "sealed": true,
359        })
360        .to_string();
361        state
362            .ledger
363            .append(KIND_CONSENT_GRANTED, &note, signer, time_unix);
364        state.credentials.push(cred.clone());
365        self.save(&state)?;
366        Ok(cred)
367    }
368
369    /// **Open a sealed payload through a credential** — the end-to-end decrypt path. Reads the credential's
370    /// `wrapped_key` (present only while active — revocation destroys it), unwraps the DEK with the
371    /// recipient's X25519 secret, verifies the content-address commitment, and AEAD-decrypts. `Err` if the
372    /// credential is unknown, revoked/expired (no key ⇒ payload unavailable), or its ciphertext is missing.
373    #[cfg(not(target_arch = "wasm32"))]
374    pub fn open_payload_via_credential(
375        &self,
376        credential_id: &str,
377        recipient_secret: &[u8; 32],
378        now_unix: u64,
379    ) -> std::io::Result<Vec<u8>> {
380        use crate::envelope_encryption::open_payload_with_wrapped;
381        let state = self.load()?;
382        let cred = state
383            .credentials
384            .iter()
385            .find(|c| c.id == credential_id)
386            .ok_or_else(|| {
387                std::io::Error::other(format!("credential '{credential_id}' not found"))
388            })?;
389        let wrapped = cred.payload_key(now_unix).ok_or_else(|| {
390            std::io::Error::other(
391                "credential revoked or expired — the wrapped key is destroyed, payload unavailable",
392            )
393        })?;
394        let payload = state
395            .payloads
396            .iter()
397            .find(|p| p.commitment == cred.payload_commitment)
398            .ok_or_else(|| {
399                std::io::Error::other("sealed payload for this credential not found in the commons")
400            })?;
401        open_payload_with_wrapped(payload, recipient_secret, wrapped).map_err(std::io::Error::other)
402    }
403
404    // --- Dead-man switch (post-death disposition; gamified + reversible) ---
405
406    /// **Arm** a dead-man switch over a payload and log it. The owner sets the liveness grace + the gamified
407    /// trigger (parties + threshold) + the disposition; it fires only when the heartbeat lapses AND a quorum
408    /// of distinct parties attest.
409    pub fn arm_dead_mans_switch(
410        &self,
411        switch: DeadMansSwitch,
412        signer: &SigningKey,
413        time_unix: u64,
414    ) -> std::io::Result<()> {
415        let mut state = self.load()?;
416        let note = serde_json::json!({
417            "commitment": hex::encode(switch.payload_commitment),
418            "threshold": switch.trigger.attestation_threshold,
419            "parties": switch.trigger.parties,
420        })
421        .to_string();
422        state
423            .ledger
424            .append(KIND_DEAD_MANS_ARMED, &note, signer, time_unix);
425        // Replace any existing switch for the same commitment (re-arm) or push.
426        if let Some(rec) = state
427            .dead_mans_switches
428            .iter_mut()
429            .find(|r| r.switch.payload_commitment == switch.payload_commitment)
430        {
431            rec.switch = switch;
432            rec.attestations.clear();
433        } else {
434            state.dead_mans_switches.push(DeadMansSwitchRecord {
435                switch,
436                attestations: Vec::new(),
437            });
438        }
439        self.save(&state)?;
440        Ok(())
441    }
442
443    /// **The principal is alive** — touch the heartbeat and un-fire a not-yet-enacted switch (the
444    /// reversibility). Returns whether a switch for `commitment` was found.
445    pub fn dead_mans_alive(
446        &self,
447        commitment: &PayloadCommitment,
448        signer: &SigningKey,
449        time_unix: u64,
450    ) -> std::io::Result<bool> {
451        let mut state = self.load()?;
452        let Some(rec) = state
453            .dead_mans_switches
454            .iter_mut()
455            .find(|r| &r.switch.payload_commitment == commitment)
456        else {
457            return Ok(false);
458        };
459        rec.switch.principal_alive(time_unix);
460        let note = serde_json::json!({ "commitment": hex::encode(commitment) }).to_string();
461        state
462            .ledger
463            .append(KIND_DEAD_MANS_ALIVE, &note, signer, time_unix);
464        self.save(&state)?;
465        Ok(true)
466    }
467
468    /// Record a **party attestation** toward a switch's trigger (the friend-side accumulation). Returns
469    /// whether the switch was found.
470    pub fn attest_dead_mans(
471        &self,
472        commitment: &PayloadCommitment,
473        attestation: PartyAttestation,
474        signer: &SigningKey,
475        time_unix: u64,
476    ) -> std::io::Result<bool> {
477        let mut state = self.load()?;
478        let Some(rec) = state
479            .dead_mans_switches
480            .iter_mut()
481            .find(|r| &r.switch.payload_commitment == commitment)
482        else {
483            return Ok(false);
484        };
485        let note = serde_json::json!({
486            "commitment": hex::encode(commitment),
487            "party": attestation.party_did,
488        })
489        .to_string();
490        // Keep the latest attestation per party.
491        rec.attestations
492            .retain(|a| a.party_did != attestation.party_did);
493        rec.attestations.push(attestation);
494        state
495            .ledger
496            .append(KIND_DEAD_MANS_ATTESTED, &note, signer, time_unix);
497        self.save(&state)?;
498        Ok(true)
499    }
500
501    /// **Enact** the switch if the gamified rule is satisfied (heartbeat lapsed + quorum attested). Records it
502    /// fired, logs it, and returns the [`Disposition`] to carry out (key-release is a separate compose step).
503    pub fn enact_dead_mans(
504        &self,
505        commitment: &PayloadCommitment,
506        signer: &SigningKey,
507        time_unix: u64,
508    ) -> std::io::Result<Option<Disposition>> {
509        let mut state = self.load()?;
510        let Some(rec) = state
511            .dead_mans_switches
512            .iter_mut()
513            .find(|r| &r.switch.payload_commitment == commitment)
514        else {
515            return Ok(None);
516        };
517        let attestations = rec.attestations.clone();
518        let disposition = rec.switch.enact(&attestations, time_unix).cloned();
519        if disposition.is_some() {
520            let note = serde_json::json!({ "commitment": hex::encode(commitment) }).to_string();
521            state
522                .ledger
523                .append(KIND_DEAD_MANS_ENACTED, &note, signer, time_unix);
524            self.save(&state)?;
525        }
526        Ok(disposition)
527    }
528
529    /// All armed dead-man switch records (with their accumulated attestations).
530    pub fn list_dead_mans_switches(&self) -> std::io::Result<Vec<DeadMansSwitchRecord>> {
531        Ok(self.load()?.dead_mans_switches)
532    }
533
534    /// **Enact a dead-man switch AND perform the key-release** — the composition that makes the disposition
535    /// real. If the switch fires with [`Disposition::ReleaseTo`], the caller-supplied `dek` (recovered by
536    /// unwrapping an owner credential) is **re-sealed to each disposition party's X25519 key** and a consent
537    /// credential is granted to them, so they can now decrypt the payload they previously could not. Each grant
538    /// is logged. `MakePublic` / `SelfDefinedRules` are returned but not key-released here (MakePublic's
539    /// irreversibility is a deferred values decision). Returns the disposition (or `None` if not triggerable).
540    #[cfg(not(target_arch = "wasm32"))]
541    pub fn enact_dead_mans_release(
542        &self,
543        commitment: &PayloadCommitment,
544        dek: &[u8; 32],
545        party_keys: &[(String, [u8; 32])],
546        subject_did: &str,
547        signer: &SigningKey,
548        time_unix: u64,
549    ) -> std::io::Result<Option<Disposition>> {
550        let mut state = self.load()?;
551        let disp = Self::enact_and_release_in_state(
552            &mut state,
553            commitment,
554            dek,
555            party_keys,
556            subject_did,
557            signer,
558            time_unix,
559        )
560        .map_err(std::io::Error::other)?;
561        if disp.is_some() {
562            self.save(&state)?;
563        }
564        Ok(disp)
565    }
566
567    /// **Social-recovery enactment (no owner key):** reconstruct the payload DEK from a quorum of friends'
568    /// Shamir shares, then enact + release to the disposition parties. This is the true post-death / incapacity
569    /// path — a quorum of chosen trustees recovers the key **without the owner**, closing the gap
570    /// [`enact_dead_mans_release`](Self::enact_dead_mans_release) left (which needed the owner's derived key).
571    #[cfg(not(target_arch = "wasm32"))]
572    pub fn reconstruct_and_release(
573        &self,
574        commitment: &PayloadCommitment,
575        recovery_shares: &[crate::shamir_recovery::Share],
576        party_keys: &[(String, [u8; 32])],
577        subject_did: &str,
578        signer: &SigningKey,
579        time_unix: u64,
580    ) -> std::io::Result<Option<Disposition>> {
581        let dek_vec =
582            crate::shamir_recovery::reconstruct(recovery_shares).map_err(std::io::Error::other)?;
583        let dek: [u8; 32] = dek_vec
584            .as_slice()
585            .try_into()
586            .map_err(|_| std::io::Error::other("reconstructed DEK is not 32 bytes"))?;
587        let mut state = self.load()?;
588        let disp = Self::enact_and_release_in_state(
589            &mut state,
590            commitment,
591            &dek,
592            party_keys,
593            subject_did,
594            signer,
595            time_unix,
596        )
597        .map_err(std::io::Error::other)?;
598        if disp.is_some() {
599            self.save(&state)?;
600        }
601        Ok(disp)
602    }
603
604    /// Shared enact-and-release logic operating on a loaded state (no IO): enacts the switch for `commitment`,
605    /// logs it, and — for a `ReleaseTo` disposition — seals `dek` to each supplied party key and grants a
606    /// credential. Returns the disposition, or `None` if not triggerable.
607    #[cfg(not(target_arch = "wasm32"))]
608    fn enact_and_release_in_state(
609        state: &mut AccountabilityState,
610        commitment: &PayloadCommitment,
611        dek: &[u8; 32],
612        party_keys: &[(String, [u8; 32])],
613        subject_did: &str,
614        signer: &SigningKey,
615        time_unix: u64,
616    ) -> Result<Option<Disposition>, String> {
617        use crate::envelope_encryption::wrap_dek_to;
618        let Some(rec) = state
619            .dead_mans_switches
620            .iter_mut()
621            .find(|r| &r.switch.payload_commitment == commitment)
622        else {
623            return Ok(None);
624        };
625        let attestations = rec.attestations.clone();
626        let Some(disposition) = rec.switch.enact(&attestations, time_unix).cloned() else {
627            return Ok(None);
628        };
629        let enote = serde_json::json!({ "commitment": hex::encode(commitment), "released": true })
630            .to_string();
631        state
632            .ledger
633            .append(KIND_DEAD_MANS_ENACTED, &enote, signer, time_unix);
634
635        if let Disposition::ReleaseTo { parties } = &disposition {
636            for did in parties.clone() {
637                let Some((_, pk)) = party_keys.iter().find(|(d, _)| d == &did) else {
638                    continue; // no key for this party yet (needs remote-key distribution)
639                };
640                let wrapped = wrap_dek_to(pk, dek)?;
641                let digest = Sha256::digest(format!("release:{did}:{time_unix}").as_bytes());
642                let id = format!("cc-{}", hex::encode(&digest[..6]));
643                let cred = ConsentCredential::grant(
644                    id.clone(),
645                    subject_did,
646                    &did,
647                    "dead-man-release",
648                    "post-death disposition",
649                    *commitment,
650                    wrapped,
651                    time_unix,
652                    None,
653                );
654                let gnote = serde_json::json!({
655                    "credential_id": id,
656                    "agent": did,
657                    "commitment": hex::encode(commitment),
658                    "via": "dead_mans_release",
659                })
660                .to_string();
661                state
662                    .ledger
663                    .append(KIND_CONSENT_GRANTED, &gnote, signer, time_unix);
664                state.credentials.push(cred);
665            }
666        }
667        Ok(Some(disposition))
668    }
669
670    // --- Incapacity switch (advocate activation on validated, reversible incapacity) ---
671
672    /// **Arm** an incapacity switch and log it. Replaces any existing switch for the same principal.
673    pub fn arm_incapacity_switch(
674        &self,
675        switch: IncapacitySwitch,
676        signer: &SigningKey,
677        time_unix: u64,
678    ) -> std::io::Result<()> {
679        let mut state = self.load()?;
680        let note = serde_json::json!({
681            "principal": switch.principal_did,
682            "advocate": switch.advocate_did,
683            "threshold": switch.trigger.attestation_threshold,
684        })
685        .to_string();
686        state
687            .ledger
688            .append(KIND_INCAPACITY_ARMED, &note, signer, time_unix);
689        if let Some(existing) = state
690            .incapacity_switches
691            .iter_mut()
692            .find(|s| s.principal_did == switch.principal_did)
693        {
694            *existing = switch;
695        } else {
696            state.incapacity_switches.push(switch);
697        }
698        self.save(&state)?;
699        Ok(())
700    }
701
702    /// **Activate** advocacy if the corroborated trigger is satisfied (quorum + optional official instrument).
703    /// Returns whether it activated.
704    pub fn activate_incapacity(
705        &self,
706        principal_did: &str,
707        attesting_parties: &[String],
708        official_instrument: Option<&str>,
709        signer: &SigningKey,
710        time_unix: u64,
711    ) -> std::io::Result<bool> {
712        let mut state = self.load()?;
713        let Some(switch) = state
714            .incapacity_switches
715            .iter_mut()
716            .find(|s| s.principal_did == principal_did)
717        else {
718            return Ok(false);
719        };
720        let activated = switch.activate(attesting_parties, official_instrument, time_unix);
721        if activated {
722            let note = serde_json::json!({
723                "principal": principal_did,
724                "official_instrument": official_instrument.is_some(),
725            })
726            .to_string();
727            state
728                .ledger
729                .append(KIND_INCAPACITY_ACTIVATED, &note, signer, time_unix);
730            self.save(&state)?;
731        }
732        Ok(activated)
733    }
734
735    /// **Regain capacity** — the advocate stands down, control reverts to the principal (the reversibility).
736    /// Returns whether a switch for the principal was found.
737    pub fn regain_capacity(
738        &self,
739        principal_did: &str,
740        signer: &SigningKey,
741        time_unix: u64,
742    ) -> std::io::Result<bool> {
743        let mut state = self.load()?;
744        let Some(switch) = state
745            .incapacity_switches
746            .iter_mut()
747            .find(|s| s.principal_did == principal_did)
748        else {
749            return Ok(false);
750        };
751        switch.regain_capacity(time_unix);
752        let note = serde_json::json!({ "principal": principal_did }).to_string();
753        state
754            .ledger
755            .append(KIND_INCAPACITY_REVERSED, &note, signer, time_unix);
756        self.save(&state)?;
757        Ok(true)
758    }
759
760    /// All armed incapacity switches.
761    pub fn list_incapacity_switches(&self) -> std::io::Result<Vec<IncapacitySwitch>> {
762        Ok(self.load()?.incapacity_switches)
763    }
764
765    // --- Disclosure traceability (ADR 0011 D5): a betrayal is knowable + attributable ---
766
767    /// Record a **transparency cc** — the protective "I informed authority X on date Y for purpose Z" note —
768    /// and log it. Durable: if the authority later betrays or fails to act, that is knowable against this.
769    pub fn record_transparency_cc(
770        &self,
771        cc: TransparencyCc,
772        signer: &SigningKey,
773        time_unix: u64,
774    ) -> std::io::Result<()> {
775        let mut state = self.load()?;
776        let note = serde_json::json!({
777            "authority": cc.informed_authority_did,
778            "credential_id": cc.credential_id,
779            "purpose": cc.purpose,
780        })
781        .to_string();
782        state
783            .ledger
784            .append(KIND_TRANSPARENCY_CC, &note, signer, time_unix);
785        state.disclosure_ccs.push(cc);
786        self.save(&state)?;
787        Ok(())
788    }
789
790    /// Record a **disclosure event** (an access or onward-share) and log it — the attributable trail. The
791    /// event's `accountable_actor` (a staffer if a delegate acted, else the recipient) is what a traced leak
792    /// points to.
793    pub fn record_disclosure_event(
794        &self,
795        event: DisclosureEvent,
796        signer: &SigningKey,
797        time_unix: u64,
798    ) -> std::io::Result<()> {
799        let mut state = self.load()?;
800        let note = serde_json::json!({
801            "commitment": hex::encode(event.payload_commitment),
802            "recipient": event.recipient_did,
803            "actor": event.accountable_actor(),
804            "id": event.id,
805        })
806        .to_string();
807        state
808            .ledger
809            .append(KIND_DISCLOSURE, &note, signer, time_unix);
810        state.disclosure_events.push(event);
811        self.save(&state)?;
812        Ok(())
813    }
814
815    /// The full disclosure chain for a payload — who saw it, via which route, in order.
816    pub fn disclosure_chain(
817        &self,
818        commitment: &PayloadCommitment,
819    ) -> std::io::Result<Vec<DisclosureEvent>> {
820        let state = self.load()?;
821        Ok(disclosure_chain(&state.disclosure_events, commitment)
822            .into_iter()
823            .cloned()
824            .collect())
825    }
826
827    /// The distinct actors who had access to a payload — the set a leak **must** be within.
828    pub fn actors_with_access(
829        &self,
830        commitment: &PayloadCommitment,
831    ) -> std::io::Result<Vec<String>> {
832        let state = self.load()?;
833        Ok(actors_with_access(&state.disclosure_events, commitment)
834            .into_iter()
835            .map(|s| s.to_string())
836            .collect())
837    }
838
839    /// **Trace a leak** by its per-recipient fingerprint → the disclosure it came from (and thence the
840    /// accountable actor). Returns the matching event, if any.
841    pub fn trace_leak(
842        &self,
843        fingerprint: &DisclosureFingerprint,
844    ) -> std::io::Result<Option<DisclosureEvent>> {
845        let state = self.load()?;
846        Ok(trace_leak(&state.disclosure_events, fingerprint).cloned())
847    }
848
849    /// All transparency cc records.
850    pub fn list_transparency_ccs(&self) -> std::io::Result<Vec<TransparencyCc>> {
851        Ok(self.load()?.disclosure_ccs)
852    }
853}
854
855/// The bytes an [`Attestation::Signature`] on a conduct record signs — the record's bound content.
856fn content_signing_bytes(
857    agent_did: &str,
858    credential_id: &str,
859    action: &str,
860    reason: &str,
861    commitment: &PayloadCommitment,
862    time_unix: u64,
863) -> [u8; 32] {
864    let mut h = Sha256::new();
865    for part in [agent_did, credential_id, action, reason] {
866        h.update(part.as_bytes());
867        h.update(b"\x1f");
868    }
869    h.update(commitment);
870    h.update(b"\x1f");
871    h.update(time_unix.to_le_bytes());
872    h.finalize().into()
873}
874
875fn conduct_id(agent_did: &str, credential_id: &str, action: &str, time_unix: u64) -> String {
876    let digest =
877        Sha256::digest(format!("{agent_did}:{credential_id}:{action}:{time_unix}").as_bytes());
878    format!("cd-{}", hex::encode(&digest[..6]))
879}
880
881/// Parse a 32-byte hex commitment (helper for the host/command boundary). `Err` on wrong length / non-hex.
882pub fn parse_commitment_hex(s: &str) -> Result<PayloadCommitment, String> {
883    let bytes = hex::decode(s.trim()).map_err(|e| format!("commitment not hex: {e}"))?;
884    bytes
885        .as_slice()
886        .try_into()
887        .map_err(|_| format!("commitment must be 32 bytes, got {}", bytes.len()))
888}
889
890#[cfg(test)]
891mod tests {
892    use super::*;
893
894    fn signer(seed: u8) -> SigningKey {
895        SigningKey::from_bytes(&[seed; 32])
896    }
897
898    fn cred(id: &str, commitment: PayloadCommitment) -> ConsentCredential {
899        ConsentCredential::grant(
900            id,
901            "did:wf:person",
902            "did:wf:social-worker",
903            "housing-support",
904            "assess and arrange support",
905            commitment,
906            b"wrapped-key".to_vec(),
907            1_000,
908            None,
909        )
910    }
911
912    #[test]
913    fn grant_then_list_persists_and_logs_to_ledger() {
914        let dir = tempfile::tempdir().unwrap();
915        let store = AccountabilityStore::open(dir.path()).unwrap();
916        let sk = signer(1);
917        store
918            .grant_credential(cred("cc-1", [7u8; 32]), &sk, 1_000)
919            .unwrap();
920
921        // Persisted across a fresh open.
922        let store2 = AccountabilityStore::open(dir.path()).unwrap();
923        let creds = store2.list_credentials().unwrap();
924        assert_eq!(creds.len(), 1);
925        assert_eq!(creds[0].id, "cc-1");
926        // The grant is in the signed chain, and the chain verifies.
927        assert_eq!(store2.verify_ledger().unwrap(), Ok(()));
928        assert_eq!(
929            store2
930                .load()
931                .unwrap()
932                .ledger
933                .of_kind(KIND_CONSENT_GRANTED)
934                .len(),
935            1
936        );
937    }
938
939    #[test]
940    fn conduct_survives_revocation_and_stays_auditable() {
941        let dir = tempfile::tempdir().unwrap();
942        let store = AccountabilityStore::open(dir.path()).unwrap();
943        let sk = signer(2);
944        let commitment = [9u8; 32];
945        store
946            .grant_credential(cred("cc-9", commitment), &sk, 1_000)
947            .unwrap();
948
949        store
950            .record_conduct(
951                "did:wf:social-worker",
952                "cc-9",
953                "accessed housing record",
954                "under consent",
955                commitment,
956                &sk,
957                1_100,
958            )
959            .unwrap();
960        store
961            .record_conduct(
962                "did:wf:social-worker",
963                "cc-9",
964                "requested placement",
965                "under consent",
966                commitment,
967                &sk,
968                1_150,
969            )
970            .unwrap();
971
972        // The person revokes — access ends (key destroyed), but the conduct trail remains.
973        assert!(store.revoke_credential("cc-9", &sk, 1_200).unwrap());
974        let creds = store.list_credentials().unwrap();
975        assert!(
976            !creds[0].payload_accessible(1_300),
977            "revoked → payload unavailable"
978        );
979
980        let trail = store.audit_trail("cc-9").unwrap();
981        assert_eq!(trail.len(), 2, "both acts survive revocation");
982        assert!(trail.iter().all(|r| r.concerns_commitment(&commitment)));
983        assert!(trail
984            .iter()
985            .all(|r| matches!(r.attestation, Attestation::Signature { .. })));
986
987        // Whole chain still verifies: grant + 2 conduct + revoke.
988        assert_eq!(store.verify_ledger().unwrap(), Ok(()));
989        assert_eq!(store.load().unwrap().ledger.len(), 4);
990    }
991
992    #[test]
993    fn a_dropped_ledger_entry_is_detected_after_reload() {
994        let dir = tempfile::tempdir().unwrap();
995        let store = AccountabilityStore::open(dir.path()).unwrap();
996        let sk = signer(3);
997        store.append_ledger("conduct", "a", &sk, 1_000).unwrap();
998        store.append_ledger("conduct", "b", &sk, 1_100).unwrap();
999        store.append_ledger("conduct", "c", &sk, 1_200).unwrap();
1000
1001        // A betrayer edits the file to remove the middle act, then we reload.
1002        let mut state = store.load().unwrap();
1003        state.ledger = {
1004            // reconstruct a ledger missing entry 1 by round-tripping through its serialized form
1005            let mut kept: Vec<_> = state.ledger.entries().to_vec();
1006            kept.remove(1);
1007            serde_json::from_value(serde_json::json!({ "entries": kept })).unwrap()
1008        };
1009        store.save(&state).unwrap();
1010
1011        assert!(
1012            matches!(store.verify_ledger().unwrap(), Err(_)),
1013            "deletion is detectable on reload"
1014        );
1015    }
1016
1017    #[cfg(not(target_arch = "wasm32"))]
1018    #[test]
1019    fn seal_grant_open_then_revoke_denies_but_bytes_survive() {
1020        use crate::envelope_encryption::EnvelopeKeypair;
1021        let dir = tempfile::tempdir().unwrap();
1022        let store = AccountabilityStore::open(dir.path()).unwrap();
1023        let sk = signer(5);
1024        let agent = EnvelopeKeypair::generate().unwrap();
1025
1026        // Seal a real record and grant the agent a credential over it.
1027        let cred = store
1028            .seal_and_grant_credential(
1029                "cc-seal",
1030                "did:wf:person",
1031                "did:wf:social-worker",
1032                "housing-support",
1033                "assess and arrange support",
1034                b"the sensitive housing record",
1035                &agent.public,
1036                vec!["did:wf:person".into(), "did:wf:archive".into()],
1037                None,
1038                &sk,
1039                1_000,
1040            )
1041            .unwrap();
1042
1043        // The agent decrypts the real ciphertext through the credential.
1044        let opened = store
1045            .open_payload_via_credential(&cred.id, &agent.secret, 1_100)
1046            .unwrap();
1047        assert_eq!(opened, b"the sensitive housing record");
1048
1049        // Nothing is stored in the clear: the persisted payload is ciphertext, not the plaintext.
1050        let st = store.load().unwrap();
1051        assert_eq!(st.payloads.len(), 1);
1052        assert_ne!(
1053            st.payloads[0].ciphertext.as_slice(),
1054            b"the sensitive housing record"
1055        );
1056
1057        // Revoke — the wrapped key is destroyed; opening now fails (no key, no payload)…
1058        assert!(store.revoke_credential(&cred.id, &sk, 1_200).unwrap());
1059        assert!(store
1060            .open_payload_via_credential(&cred.id, &agent.secret, 1_300)
1061            .is_err());
1062        // …though the commons ciphertext survives (revocation is access, not deletion).
1063        let st = store.load().unwrap();
1064        assert_eq!(st.payloads.len(), 1);
1065        assert!(st.payloads[0].is_durable());
1066        // The whole ledger (grant + revoke) still verifies.
1067        assert_eq!(store.verify_ledger().unwrap(), Ok(()));
1068    }
1069
1070    #[test]
1071    fn dead_mans_switch_gamified_trigger_and_reversibility() {
1072        use crate::dead_mans_switch::{
1073            AttestationKind, DeadMansSwitch, Disposition, Heartbeat, PartyAttestation, TriggerRule,
1074        };
1075        let dir = tempfile::tempdir().unwrap();
1076        let store = AccountabilityStore::open(dir.path()).unwrap();
1077        let sk = signer(6);
1078        let c = [3u8; 32];
1079        let att = |who: &str, t: u64| PartyAttestation {
1080            party_did: who.into(),
1081            kind: AttestationKind::BelievedDead,
1082            time_unix: t,
1083        };
1084        let sw = DeadMansSwitch {
1085            payload_commitment: c,
1086            heartbeat: Heartbeat::new(1_000, 100),
1087            trigger: TriggerRule {
1088                require_heartbeat_lapsed: true,
1089                attestation_threshold: 2,
1090                parties: vec!["a".into(), "b".into()],
1091            },
1092            disposition: Disposition::ReleaseTo {
1093                parties: vec!["trustee".into()],
1094            },
1095            fired_unix: None,
1096        };
1097        store.arm_dead_mans_switch(sw, &sk, 1_000).unwrap();
1098
1099        // One party alone can't fire it (gamified — quorum required).
1100        store
1101            .attest_dead_mans(&c, att("a", 1_200), &sk, 1_200)
1102            .unwrap();
1103        assert!(store.enact_dead_mans(&c, &sk, 1_200).unwrap().is_none());
1104        // Two distinct parties + lapsed heartbeat → triggerable, but the principal showing up resets it.
1105        store
1106            .attest_dead_mans(&c, att("b", 1_200), &sk, 1_200)
1107            .unwrap();
1108        assert!(
1109            store.dead_mans_alive(&c, &sk, 1_200).unwrap(),
1110            "principal alive = reversibility"
1111        );
1112        assert!(
1113            store.enact_dead_mans(&c, &sk, 1_250).unwrap().is_none(),
1114            "alive at 1200, grace 100 → not lapsed until 1300"
1115        );
1116        // Later, no further aliveness; heartbeat lapsed again + quorum persists → it enacts its disposition.
1117        let disp = store.enact_dead_mans(&c, &sk, 1_400).unwrap();
1118        assert!(matches!(disp, Some(Disposition::ReleaseTo { .. })));
1119        // The whole chain (arm + attest×2 + alive + enact) verifies.
1120        assert_eq!(store.verify_ledger().unwrap(), Ok(()));
1121    }
1122
1123    #[cfg(not(target_arch = "wasm32"))]
1124    #[test]
1125    fn dead_mans_release_hands_the_key_to_the_disposition_party() {
1126        use crate::dead_mans_switch::{
1127            AttestationKind, DeadMansSwitch, Disposition, Heartbeat, PartyAttestation, TriggerRule,
1128        };
1129        use crate::envelope_encryption::{
1130            open_payload_with_wrapped, seal_payload, EnvelopeKeypair,
1131        };
1132        let dir = tempfile::tempdir().unwrap();
1133        let store = AccountabilityStore::open(dir.path()).unwrap();
1134        let sk = signer(9);
1135        let trustee = EnvelopeKeypair::generate().unwrap();
1136
1137        // Seal a payload; persist the ciphertext. The DEK is what the switch will hand over on enact.
1138        let (payload, dek) =
1139            seal_payload(b"the estate letter", vec!["did:wf:person".into()]).unwrap();
1140        let c = payload.commitment;
1141        {
1142            let mut st = store.load().unwrap();
1143            st.payloads.push(payload);
1144            store.save(&st).unwrap();
1145        }
1146        // Before enactment the trustee has no credential — no way in.
1147        assert!(store
1148            .wrapped_key_for(&c, "did:wf:trustee", 1_100)
1149            .unwrap()
1150            .is_none());
1151
1152        // Arm a switch releasing to the trustee; a friend attests; heartbeat lapses.
1153        store
1154            .arm_dead_mans_switch(
1155                DeadMansSwitch {
1156                    payload_commitment: c,
1157                    heartbeat: Heartbeat::new(1_000, 100),
1158                    trigger: TriggerRule {
1159                        require_heartbeat_lapsed: true,
1160                        attestation_threshold: 1,
1161                        parties: vec!["did:wf:friend".into()],
1162                    },
1163                    disposition: Disposition::ReleaseTo {
1164                        parties: vec!["did:wf:trustee".into()],
1165                    },
1166                    fired_unix: None,
1167                },
1168                &sk,
1169                1_000,
1170            )
1171            .unwrap();
1172        store
1173            .attest_dead_mans(
1174                &c,
1175                PartyAttestation {
1176                    party_did: "did:wf:friend".into(),
1177                    kind: AttestationKind::BelievedDead,
1178                    time_unix: 1_200,
1179                },
1180                &sk,
1181                1_200,
1182            )
1183            .unwrap();
1184
1185        // Enact + release the DEK to the trustee's X25519 key.
1186        let party_keys = vec![("did:wf:trustee".to_string(), trustee.public)];
1187        let disp = store
1188            .enact_dead_mans_release(&c, &dek, &party_keys, "did:wf:person", &sk, 1_200)
1189            .unwrap();
1190        assert!(matches!(disp, Some(Disposition::ReleaseTo { .. })));
1191
1192        // The trustee now holds a credential whose wrapped key opens the payload with the trustee's secret —
1193        // access was genuinely handed over by the crypto, not just recorded.
1194        let st = store.load().unwrap();
1195        let cred = st
1196            .credentials
1197            .iter()
1198            .find(|c2| c2.agent_did == "did:wf:trustee")
1199            .expect("trustee credential granted on enact");
1200        let wrapped = cred.payload_key(1_300).expect("live wrapped key");
1201        let payload = st.payloads.iter().find(|p| p.commitment == c).unwrap();
1202        let opened = open_payload_with_wrapped(payload, &trustee.secret, wrapped).unwrap();
1203        assert_eq!(opened, b"the estate letter");
1204        assert_eq!(store.verify_ledger().unwrap(), Ok(()));
1205    }
1206
1207    #[test]
1208    fn incapacity_switch_activates_with_quorum_and_reverses() {
1209        use crate::incapacity_switch::{IncapacityKind, IncapacitySwitch, IncapacityTrigger};
1210        let dir = tempfile::tempdir().unwrap();
1211        let store = AccountabilityStore::open(dir.path()).unwrap();
1212        let sk = signer(7);
1213        let sw = IncapacitySwitch {
1214            principal_did: "did:wf:person".into(),
1215            kind: IncapacityKind::InvoluntaryPsychiatric,
1216            trigger: IncapacityTrigger {
1217                parties: vec!["adv".into(), "friend".into()],
1218                attestation_threshold: 2,
1219                require_official_instrument: false,
1220            },
1221            advocate_did: "did:wf:advocate".into(),
1222            active_since_unix: None,
1223        };
1224        store.arm_incapacity_switch(sw, &sk, 1_000).unwrap();
1225        // One attester is not enough (corroboration required).
1226        assert!(!store
1227            .activate_incapacity("did:wf:person", &["adv".into()], None, &sk, 1_100)
1228            .unwrap());
1229        // Quorum → advocate activates.
1230        assert!(store
1231            .activate_incapacity(
1232                "did:wf:person",
1233                &["adv".into(), "friend".into()],
1234                None,
1235                &sk,
1236                1_100
1237            )
1238            .unwrap());
1239        assert!(store.list_incapacity_switches().unwrap()[0].advocate_active());
1240        // Recovery reverses it.
1241        assert!(store.regain_capacity("did:wf:person", &sk, 1_500).unwrap());
1242        assert!(!store.list_incapacity_switches().unwrap()[0].advocate_active());
1243        assert_eq!(store.verify_ledger().unwrap(), Ok(()));
1244    }
1245
1246    #[cfg(not(target_arch = "wasm32"))]
1247    #[test]
1248    fn social_recovery_reconstructs_the_key_without_the_owner() {
1249        use crate::dead_mans_switch::{
1250            AttestationKind, DeadMansSwitch, Disposition, Heartbeat, PartyAttestation, TriggerRule,
1251        };
1252        use crate::envelope_encryption::{
1253            open_payload_with_wrapped, seal_payload, EnvelopeKeypair,
1254        };
1255        use crate::shamir_recovery::split;
1256        let dir = tempfile::tempdir().unwrap();
1257        let store = AccountabilityStore::open(dir.path()).unwrap();
1258        let sk = signer(10);
1259        let trustee = EnvelopeKeypair::generate().unwrap();
1260
1261        // Owner seals a payload (while alive); the DEK is split 2-of-3 among friends and handed out.
1262        let (payload, dek) =
1263            seal_payload(b"the will and testament", vec!["did:wf:person".into()]).unwrap();
1264        let c = payload.commitment;
1265        {
1266            let mut st = store.load().unwrap();
1267            st.payloads.push(payload);
1268            store.save(&st).unwrap();
1269        }
1270        let shares = split(&dek, 2, 3).unwrap();
1271
1272        store
1273            .arm_dead_mans_switch(
1274                DeadMansSwitch {
1275                    payload_commitment: c,
1276                    heartbeat: Heartbeat::new(1_000, 100),
1277                    trigger: TriggerRule {
1278                        require_heartbeat_lapsed: true,
1279                        attestation_threshold: 1,
1280                        parties: vec!["did:wf:friend".into()],
1281                    },
1282                    disposition: Disposition::ReleaseTo {
1283                        parties: vec!["did:wf:trustee".into()],
1284                    },
1285                    fired_unix: None,
1286                },
1287                &sk,
1288                1_000,
1289            )
1290            .unwrap();
1291        store
1292            .attest_dead_mans(
1293                &c,
1294                PartyAttestation {
1295                    party_did: "did:wf:friend".into(),
1296                    kind: AttestationKind::BelievedDead,
1297                    time_unix: 1_200,
1298                },
1299                &sk,
1300                1_200,
1301            )
1302            .unwrap();
1303
1304        // Post-death: TWO friends combine their shares — **the owner key is never used** — to reconstruct the
1305        // DEK and release it to the trustee.
1306        let quorum = vec![shares[0].clone(), shares[2].clone()];
1307        let party_keys = vec![("did:wf:trustee".to_string(), trustee.public)];
1308        let disp = store
1309            .reconstruct_and_release(&c, &quorum, &party_keys, "did:wf:person", &sk, 1_200)
1310            .unwrap();
1311        assert!(matches!(disp, Some(Disposition::ReleaseTo { .. })));
1312
1313        // The trustee opens the payload — recovered entirely from friends' shares, no owner key involved.
1314        let st = store.load().unwrap();
1315        let cred = st
1316            .credentials
1317            .iter()
1318            .find(|c2| c2.agent_did == "did:wf:trustee")
1319            .unwrap();
1320        let wrapped = cred.payload_key(1_300).unwrap();
1321        let p = st.payloads.iter().find(|p| p.commitment == c).unwrap();
1322        assert_eq!(
1323            open_payload_with_wrapped(p, &trustee.secret, wrapped).unwrap(),
1324            b"the will and testament"
1325        );
1326        assert_eq!(store.verify_ledger().unwrap(), Ok(()));
1327    }
1328
1329    #[test]
1330    fn disclosure_trace_records_and_attributes_a_staff_leak() {
1331        use crate::disclosure_trace::{DisclosureEvent, DisclosureKind, TransparencyCc};
1332        let dir = tempfile::tempdir().unwrap();
1333        let store = AccountabilityStore::open(dir.path()).unwrap();
1334        let sk = signer(8);
1335        let c = [9u8; 32];
1336        let fp_staff = [2u8; 16];
1337
1338        // The person cc's the MP (protective record), then the MP accesses, then the MP's staffer leaks onward.
1339        store
1340            .record_transparency_cc(
1341                TransparencyCc {
1342                    credential_id: "cc-t".into(),
1343                    informed_authority_did: "did:wf:mp".into(),
1344                    purpose: "protection from serious crime".into(),
1345                    informed_unix: 1_000,
1346                },
1347                &sk,
1348                1_000,
1349            )
1350            .unwrap();
1351        store
1352            .record_disclosure_event(
1353                DisclosureEvent {
1354                    id: "d1".into(),
1355                    payload_commitment: c,
1356                    credential_id: "cc-t".into(),
1357                    recipient_did: "did:wf:mp".into(),
1358                    acting_delegate_did: None,
1359                    time_unix: 1_100,
1360                    fingerprint: [1u8; 16],
1361                    kind: DisclosureKind::DirectAccess,
1362                },
1363                &sk,
1364                1_100,
1365            )
1366            .unwrap();
1367        store
1368            .record_disclosure_event(
1369                DisclosureEvent {
1370                    id: "d2".into(),
1371                    payload_commitment: c,
1372                    credential_id: "cc-t".into(),
1373                    recipient_did: "did:wf:mp".into(),
1374                    acting_delegate_did: Some("did:wf:staffer".into()),
1375                    time_unix: 1_200,
1376                    fingerprint: fp_staff,
1377                    kind: DisclosureKind::OnwardShare {
1378                        to_did: "did:wf:perpetrator".into(),
1379                    },
1380                },
1381                &sk,
1382                1_200,
1383            )
1384            .unwrap();
1385
1386        assert_eq!(store.disclosure_chain(&c).unwrap().len(), 2);
1387        let actors = store.actors_with_access(&c).unwrap();
1388        assert!(actors.contains(&"did:wf:mp".to_string()));
1389        assert!(
1390            actors.contains(&"did:wf:staffer".to_string()),
1391            "the acting staffer is in the access set"
1392        );
1393        // The leaked fingerprint traces to the staffer as the accountable actor — the betrayal is knowable.
1394        let ev = store.trace_leak(&fp_staff).unwrap().unwrap();
1395        assert_eq!(ev.accountable_actor(), "did:wf:staffer");
1396        assert_eq!(store.verify_ledger().unwrap(), Ok(()));
1397    }
1398
1399    #[test]
1400    fn parse_commitment_hex_roundtrips() {
1401        let c = [0x2au8; 32];
1402        let hexed = hex::encode(c);
1403        assert_eq!(parse_commitment_hex(&hexed).unwrap(), c);
1404        assert!(parse_commitment_hex("zz").is_err());
1405        assert!(
1406            parse_commitment_hex("2a2a").is_err(),
1407            "wrong length rejected"
1408        );
1409    }
1410}