Skip to main content

qualia_client_core/
consent_credential.rs

1//! **Revocable consent credentials** with *crypto-enforced* payload revocation + a **durable, attestable
2//! conduct record** — the mechanism that resolves "revocable data vs durable accountability"
3//! (`docs/plans/social-worker-support-and-accountability.md` §2 + §4).
4//!
5//! The consideration (Timothy, 2026-07-06): a person can grant an agent (a social worker — a human) a
6//! **consent credential** to an **encrypted payload**, and can **take it away**. When taken away the
7//! **payload becomes unavailable** — *not* by flipping a flag, but by **destroying the key** (envelope
8//! encryption: the payload is `Enc(data_key, payload)`; the credential carries the *wrapped* data key;
9//! revoke ⇒ the wrapped key is destroyed ⇒ no key, no payload). **But the agent's interaction records
10//! persist** — how and why they acted — so revoking consent cannot erase a worker's accountability, and a
11//! worker cannot hold the person's data hostage *for* accountability. The durable [`ConductRecord`] binds to
12//! a **commitment** of the payload (not the payload), and carries an [`Attestation`] (a signature and/or a
13//! zero-knowledge proof over the real `crypto/zk_proofs` system) so a **court can audit** that the agent
14//! acted, on what basis, at what time — *without* re-exposing the revoked private data.
15//!
16//! **The payload lives in a permissive commons** (Timothy, 2026-07-06). The ciphertext is an
17//! [`EncryptedCommonsPayload`] — **stored/replicated by many parties so it cannot be deleted** (anti-erasure:
18//! not by an agent covering their tracks, not by a hostile actor destroying evidence, not by accidental
19//! loss) — **yet accessible only to holders of the right credential**. Wide storage ≠ wide access: the
20//! ciphertext is useless without a key. This resolves *anti-deletion vs privacy vs access-control* at once,
21//! and it sharpens revocation: you cannot delete bytes others hold, so **revocation is access, not
22//! deletion** — revoke a credential ⇒ that holder's key is destroyed ⇒ they lose access, while the durable
23//! commons ciphertext persists for other holders and as un-erasable evidence. The person's *ultimate*
24//! control is **crypto-shredding**: destroy **all** keys ⇒ the ciphertext is permanently unreadable by
25//! anyone (effective erasure) even though the bytes survive. (Continuous with the permissive-commons +
26//! distributed-memory-custody + erasure-prevention stances elsewhere.)
27//!
28//! **Credentials are not only self-consent, and need not be unilateral** (Timothy, 2026-07-06). A
29//! credential's authority may derive from the **subject**, from a **court** (to support proceedings / the
30//! audit case), or from another attested **authority** (a statutory body, a guardian) — see
31//! [`CredentialAuthority`]. And a credential may be **multi-signature** ([`Authorization::MultiSig`]): an
32//! exercise then requires (a) **instigation by a participating party** — so no outside/authority actor can
33//! act alone — **and** (b) a **threshold** of party signatures. Even a valid court credential, if multi-sig,
34//! cannot be exercised without a participating party setting it in motion and the threshold signing. This is
35//! the check on authority: *unable to act without instigation of one of the participating parties.*
36//!
37//! **Scope of this module.** The pure **domain model + the invariants** — the commons payload, the
38//! *revocable* per-holder access, the court/authority + multi-sig authorization, and the *durable* conduct
39//! trail. It does **not** perform the actual
40//! envelope encryption, the real Groth16 proof, the replication/seeding, or the `consent_store`/vault
41//! wiring — those compose from `wellfair/consent_store.rs` (whose flag-`revoke` this design says should
42//! become crypto-enforced), `wellfair/vault.rs`, the WebTorrent/seeder layer (the commons replication), and
43//! `qualia-core-db::crypto::zk_proofs` (coordinate). This is the shape the wiring must honour.
44
45use serde::{Deserialize, Serialize};
46
47/// A commitment to a payload — a 32-byte hash/commitment (e.g. BLAKE3/SHA-256, computed by the crypto
48/// layer). It **survives revocation** and binds a [`ConductRecord`] to *what* was acted on, without holding
49/// or re-exposing the payload itself. It is also the **content address** of the [`EncryptedCommonsPayload`].
50pub type PayloadCommitment = [u8; 32];
51
52/// An **encrypted payload in a permissive commons** — content-addressed ciphertext that **many parties may
53/// store (replicate)** so it **cannot be deleted** (anti-erasure), but that is **accessible only to holders
54/// of the right credential** (the wrapped decryption key). Wide storage ≠ wide access: without a key the
55/// ciphertext is opaque.
56///
57/// Revocation acts on **access** (the credential's key — see [`ConsentCredential::revoke`]), *not* on this
58/// blob; the blob persists for other credential-holders and as un-erasable evidence. The person's ultimate
59/// control is **crypto-shredding** — once no credential can decrypt it (all keys destroyed), it is
60/// permanently unreadable by anyone, effective erasure though the bytes survive (see
61/// [`is_crypto_shredded`]).
62#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
63pub struct EncryptedCommonsPayload {
64    /// Content address / commitment of the ciphertext — its durable identifier (equals the
65    /// [`PayloadCommitment`] a credential and conduct record bind to).
66    pub commitment: PayloadCommitment,
67    /// The envelope-encrypted bytes. Opaque without a credential; safe to replicate widely.
68    pub ciphertext: Vec<u8>,
69    /// The parties (by identifier) who hold a copy — the commons. Replication = durability: no single
70    /// party's deletion destroys the payload while another copy exists.
71    pub storers: Vec<String>,
72}
73
74impl EncryptedCommonsPayload {
75    pub fn new(commitment: PayloadCommitment, ciphertext: Vec<u8>, storers: Vec<String>) -> Self {
76        Self {
77            commitment,
78            ciphertext,
79            storers,
80        }
81    }
82
83    /// Number of independent copies held — the anti-deletion / durability measure.
84    pub fn replication(&self) -> usize {
85        self.storers.len()
86    }
87
88    /// Durable against unilateral deletion (held by more than one storer). A single-copy payload is *not*
89    /// yet in a resilient commons.
90    pub fn is_durable(&self) -> bool {
91        self.storers.len() > 1
92    }
93
94    /// Add a storer (a party replicates a copy — increases durability). Idempotent.
95    pub fn add_storer(&mut self, did: impl Into<String>) {
96        let did = did.into();
97        if !self.storers.iter().any(|s| s == &did) {
98            self.storers.push(did);
99        }
100    }
101
102    /// One storer drops their copy. Returns `true` if copies **remain** (the payload survives) — the point
103    /// of the commons: unilateral deletion does not erase what others hold.
104    pub fn drop_storer(&mut self, did: &str) -> bool {
105        self.storers.retain(|s| s != did);
106        !self.storers.is_empty()
107    }
108}
109
110/// **Crypto-shredding check.** Is this commons payload *effectively erased* — permanently unreadable — for a
111/// given set of credentials at `now`? True iff **no** credential grants a live key to it (every credential
112/// for this commitment is revoked/expired, or none exists). The bytes may still be replicated across the
113/// commons, but with no key anywhere they cannot be decrypted by anyone — the person's ultimate erasure
114/// control, achieved by destroying keys rather than by chasing copies.
115pub fn is_crypto_shredded(
116    payload: &EncryptedCommonsPayload,
117    credentials: &[ConsentCredential],
118    now_unix: u64,
119) -> bool {
120    !credentials
121        .iter()
122        .any(|c| c.payload_commitment == payload.commitment && c.payload_key(now_unix).is_some())
123}
124
125/// Where a credential's authority derives from — the *basis* on which access is granted. Not always
126/// self-consent: a **court** can hold one (to support proceedings), as can another attested **authority**.
127/// Authority-issued credentials are legitimate — and, when multi-sig, still cannot be exercised unilaterally.
128#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
129#[serde(rename_all = "snake_case")]
130pub enum CredentialAuthority {
131    /// The data-subject's own consent.
132    #[default]
133    Subject,
134    /// A court / judicial order — supports court-of-law access (audit / proceedings).
135    Court { order_ref: String },
136    /// Another attested authoritative agent (a statutory body, a guardian, …), by its instrument.
137    Authority {
138        authority_did: String,
139        instrument_ref: String,
140    },
141}
142
143/// A participating party in a multi-signature authorization.
144#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
145pub struct Party {
146    pub did: String,
147    /// The party's role in the authorization (e.g. `"subject"`, `"guardian"`, `"advocate"`, `"court"`).
148    pub role: String,
149}
150
151/// How a credential may be **exercised** (acted on the payload).
152#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
153#[serde(rename_all = "snake_case")]
154pub enum Authorization {
155    /// The holder may act alone.
156    #[default]
157    Single,
158    /// **Multi-signature.** An exercise must be (a) **instigated by one of the participating parties** — so
159    /// no outside/authority actor can act unilaterally — AND (b) signed by at least `threshold` of the
160    /// `parties`. The "unable to act without instigation of one of the participating parties" rule.
161    MultiSig {
162        parties: Vec<Party>,
163        threshold: usize,
164    },
165}
166
167/// A request to exercise a credential: **who instigated it**, and the party signatures collected.
168#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
169pub struct ExerciseRequest {
170    pub instigator_did: String,
171    /// DIDs of parties who have signed off on this exercise.
172    pub signatures: Vec<String>,
173}
174
175impl ExerciseRequest {
176    pub fn new(instigator_did: impl Into<String>, signatures: Vec<String>) -> Self {
177        Self {
178            instigator_did: instigator_did.into(),
179            signatures,
180        }
181    }
182}
183
184impl Authorization {
185    /// Is this exercise authorised? `Single` → always. `MultiSig` → the instigator must be a participating
186    /// party (no unilateral outside/authority action) AND at least `threshold` **distinct** participating
187    /// parties must have signed.
188    pub fn authorizes(&self, req: &ExerciseRequest) -> bool {
189        match self {
190            Authorization::Single => true,
191            Authorization::MultiSig { parties, threshold } => {
192                let is_party = |did: &str| parties.iter().any(|p| p.did == did);
193                if !is_party(&req.instigator_did) {
194                    return false; // must be instigated by a participating party
195                }
196                let signers: std::collections::BTreeSet<&str> = req
197                    .signatures
198                    .iter()
199                    .map(|s| s.as_str())
200                    .filter(|s| is_party(s))
201                    .collect();
202                signers.len() >= *threshold
203            }
204        }
205    }
206}
207
208/// A **consent credential** — grants an agent scoped access to an encrypted payload; revocable, with the
209/// revocation *crypto-enforced* (the wrapped key is destroyed). Its authority may be self-consent, a court,
210/// or another authority ([`CredentialAuthority`]); its exercise may require multi-sig
211/// ([`Authorization`]).
212#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
213pub struct ConsentCredential {
214    pub id: String,
215    /// The person granting consent (the data-subject).
216    pub subject_did: String,
217    /// The granted agent (e.g. the social worker — a human, by identifier).
218    pub agent_did: String,
219    /// What is granted, purpose-bound (minimal-disclosure scope).
220    pub scope: String,
221    pub purpose: String,
222    /// A commitment to the granted payload — durable, binds the conduct trail.
223    pub payload_commitment: PayloadCommitment,
224    pub granted_unix: u64,
225    /// Optional expiry — access ceases at/after this time even without an explicit revoke.
226    pub expiry_unix: Option<u64>,
227    /// Set on revoke — the moment after which the payload is unavailable.
228    pub revoked_unix: Option<u64>,
229    /// The **wrapped data key** the agent needs to decrypt the payload — present while access is permitted,
230    /// **destroyed (`None`) on revoke/expiry**. This is the crypto-revocation: no key ⇒ no payload. Private
231    /// so it can only be read through [`payload_key`](ConsentCredential::payload_key), which enforces the
232    /// active check.
233    #[serde(default, skip_serializing_if = "Option::is_none")]
234    wrapped_key: Option<Vec<u8>>,
235    /// Where this credential's authority derives from (subject / court / authority).
236    #[serde(default)]
237    pub authority: CredentialAuthority,
238    /// How it may be exercised (alone, or multi-sig requiring party instigation + threshold).
239    #[serde(default)]
240    pub authorization: Authorization,
241}
242
243impl ConsentCredential {
244    /// Grant a credential. `wrapped_key` is the data key wrapped for the agent (from the vault's envelope
245    /// encryption) — the thing revocation destroys.
246    #[allow(clippy::too_many_arguments)]
247    pub fn grant(
248        id: impl Into<String>,
249        subject_did: impl Into<String>,
250        agent_did: impl Into<String>,
251        scope: impl Into<String>,
252        purpose: impl Into<String>,
253        payload_commitment: PayloadCommitment,
254        wrapped_key: Vec<u8>,
255        granted_unix: u64,
256        expiry_unix: Option<u64>,
257    ) -> Self {
258        Self {
259            id: id.into(),
260            subject_did: subject_did.into(),
261            agent_did: agent_did.into(),
262            scope: scope.into(),
263            purpose: purpose.into(),
264            payload_commitment,
265            granted_unix,
266            expiry_unix,
267            revoked_unix: None,
268            wrapped_key: Some(wrapped_key),
269            authority: CredentialAuthority::Subject,
270            authorization: Authorization::Single,
271        }
272    }
273
274    /// Set the credential's authority basis (a court order / another authority). Builder-style.
275    pub fn with_authority(mut self, authority: CredentialAuthority) -> Self {
276        self.authority = authority;
277        self
278    }
279
280    /// Require **multi-sig** exercise: instigation by a participating party + `threshold` party signatures.
281    /// Builder-style. This is what makes the credential *unable to act without a participating party*.
282    pub fn requiring_multisig(mut self, parties: Vec<Party>, threshold: usize) -> Self {
283        self.authorization = Authorization::MultiSig { parties, threshold };
284        self
285    }
286
287    /// Whether the credential is currently active (not revoked, not past expiry).
288    pub fn is_active(&self, now_unix: u64) -> bool {
289        if self.revoked_unix.is_some() {
290            return false;
291        }
292        match self.expiry_unix {
293            Some(exp) => now_unix < exp,
294            None => true,
295        }
296    }
297
298    /// **Revoke** the credential — *crypto-enforced*: records the moment **and destroys the wrapped key**,
299    /// so the payload can no longer be decrypted (it returns to the person). Idempotent.
300    pub fn revoke(&mut self, now_unix: u64) {
301        if self.revoked_unix.is_none() {
302            self.revoked_unix = Some(now_unix);
303        }
304        self.wrapped_key = None; // the key is gone — no key, no payload
305    }
306
307    /// The wrapped data key **iff access is currently permitted** — `None` once revoked or expired. A `None`
308    /// here *is* "the payload is unavailable to the agent": with no key there is nothing to decrypt with.
309    pub fn payload_key(&self, now_unix: u64) -> Option<&[u8]> {
310        if self.is_active(now_unix) {
311            self.wrapped_key.as_deref()
312        } else {
313            None
314        }
315    }
316
317    /// Whether the encrypted payload is *technically* accessible right now (active + key present) —
318    /// **ignoring** any multi-sig requirement. For a multi-sig credential use [`can_exercise`] /
319    /// [`exercise`], which enforce party-instigation + threshold.
320    ///
321    /// [`can_exercise`]: ConsentCredential::can_exercise
322    /// [`exercise`]: ConsentCredential::exercise
323    pub fn payload_accessible(&self, now_unix: u64) -> bool {
324        self.payload_key(now_unix).is_some()
325    }
326
327    /// Whether a specific exercise is permitted: the credential is active **and** the request satisfies the
328    /// [`Authorization`] (for multi-sig: instigated by a participating party + threshold signatures). This is
329    /// the gate that stops an authority acting unilaterally.
330    pub fn can_exercise(&self, req: &ExerciseRequest, now_unix: u64) -> bool {
331        self.is_active(now_unix) && self.authorization.authorizes(req)
332    }
333
334    /// The wrapped data key **iff this exercise is authorised** (active + authorization satisfied). The
335    /// authorization-aware counterpart to [`payload_key`](ConsentCredential::payload_key): a multi-sig
336    /// credential yields a key only when a participating party instigated it and the threshold has signed.
337    pub fn exercise(&self, req: &ExerciseRequest, now_unix: u64) -> Option<&[u8]> {
338        if self.can_exercise(req, now_unix) {
339            self.wrapped_key.as_deref()
340        } else {
341            None
342        }
343    }
344}
345
346/// The cryptographic **attestation** on a [`ConductRecord`] — attributable + court-auditable.
347#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
348#[serde(rename_all = "snake_case")]
349pub enum Attestation {
350    /// A detached signature over the record (ed25519 / ML-DSA) — binds the agent to the conduct. Hex.
351    Signature { alg: String, sig_hex: String },
352    /// A **zero-knowledge proof** over `crypto/zk_proofs` — proves a property (e.g. "the agent held a valid
353    /// consent credential for the payload committed to by `payload_commitment` at `time`") **without
354    /// revealing the payload**. Referenced by id; the proof + verifying key live in the ZK layer.
355    ZkProof { proof_id: String },
356}
357
358/// A **durable** record of *how and why* an agent acted — the conduct trail. It **persists after the
359/// consent credential is revoked and the payload is gone** (revoking consent does not erase accountability),
360/// and it binds to the payload **commitment** (not the payload), so it proves the agent acted on a specific
361/// datum **without** retaining or re-exposing that datum. Append-only in practice (the store is
362/// tamper-evident — signed WAL); this type is the record.
363#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
364pub struct ConductRecord {
365    pub id: String,
366    /// The agent who acted (a human worker — accountable).
367    pub agent_did: String,
368    /// The consent credential the action was taken under (links act → authority).
369    pub credential_id: String,
370    /// What the agent did (accessed / decided / referred / requested / escalated / **omitted**).
371    pub action: String,
372    /// Why — the stated basis/authority for the act.
373    pub reason: String,
374    pub time_unix: u64,
375    /// Binds the record to *what* was acted on — the payload commitment (durable; the payload is not held).
376    pub payload_commitment: PayloadCommitment,
377    /// The attestation making this court-auditable and attributable.
378    pub attestation: Attestation,
379}
380
381impl ConductRecord {
382    /// Does this conduct record concern the payload committed to by `commitment`? (Audit link — verify the
383    /// agent acted on a specific datum by its commitment, without needing the datum.)
384    pub fn concerns_commitment(&self, commitment: &PayloadCommitment) -> bool {
385        &self.payload_commitment == commitment
386    }
387}
388
389/// Filter a conduct trail to the records taken under one consent credential — the **audit view**. These are
390/// exactly the records that survive that credential's revocation (the accountability the person cannot erase
391/// and the worker cannot withhold).
392pub fn audit_trail_for_credential<'a>(
393    records: &'a [ConductRecord],
394    credential_id: &str,
395) -> Vec<&'a ConductRecord> {
396    records
397        .iter()
398        .filter(|r| r.credential_id == credential_id)
399        .collect()
400}
401
402#[cfg(test)]
403mod tests {
404    use super::*;
405
406    const C: PayloadCommitment = [7u8; 32];
407
408    fn cred() -> ConsentCredential {
409        ConsentCredential::grant(
410            "cc-1",
411            "did:wf:person",
412            "did:wf:social-worker",
413            "housing-support-case",
414            "assess and arrange support",
415            C,
416            b"wrapped-data-key".to_vec(),
417            1_000,
418            Some(2_000),
419        )
420    }
421
422    fn conduct(id: &str, cred_id: &str, action: &str) -> ConductRecord {
423        ConductRecord {
424            id: id.into(),
425            agent_did: "did:wf:social-worker".into(),
426            credential_id: cred_id.into(),
427            action: action.into(),
428            reason: "acting under the granted consent".into(),
429            time_unix: 1_100,
430            payload_commitment: C,
431            attestation: Attestation::Signature {
432                alg: "ed25519".into(),
433                sig_hex: "deadbeef".into(),
434            },
435        }
436    }
437
438    #[test]
439    fn granted_payload_is_accessible_until_revoked() {
440        let c = cred();
441        assert!(
442            c.payload_accessible(1_100),
443            "active grant → payload accessible"
444        );
445        assert!(c.payload_key(1_100).is_some());
446    }
447
448    #[test]
449    fn revocation_is_crypto_enforced_the_key_is_destroyed_and_payload_gone() {
450        let mut c = cred();
451        assert!(c.payload_accessible(1_100));
452        c.revoke(1_200);
453        // No key, no payload — not a flag, the wrapped key is gone.
454        assert!(
455            !c.payload_accessible(1_300),
456            "revoked → payload unavailable"
457        );
458        assert!(
459            c.payload_key(1_300).is_none(),
460            "the wrapped key is destroyed"
461        );
462        assert_eq!(c.revoked_unix, Some(1_200));
463        // Even serialized, the key does not travel (it's gone / skipped).
464        let json = serde_json::to_string(&c).unwrap();
465        assert!(
466            !json.contains("wrapped"),
467            "no key material persists after revoke"
468        );
469    }
470
471    #[test]
472    fn expiry_also_makes_the_payload_unavailable() {
473        let c = cred(); // expires at 2_000
474        assert!(c.payload_accessible(1_999));
475        assert!(!c.payload_accessible(2_000), "past expiry → no access");
476        assert!(!c.payload_accessible(2_001));
477    }
478
479    #[test]
480    fn conduct_records_persist_after_revocation_and_stay_auditable() {
481        // The person revokes; the payload is gone — but the worker's conduct trail remains.
482        let mut c = cred();
483        let trail = vec![
484            conduct("k1", "cc-1", "accessed the housing record"),
485            conduct("k2", "cc-1", "requested an emergency placement"),
486            conduct("k3", "other-cred", "unrelated act"),
487        ];
488        c.revoke(1_500);
489        assert!(!c.payload_accessible(1_600), "data gone");
490
491        // The conduct trail for this credential is untouched by revocation — the accountability survives.
492        let audit = audit_trail_for_credential(&trail, "cc-1");
493        assert_eq!(
494            audit.len(),
495            2,
496            "both acts under cc-1 remain auditable after revoke"
497        );
498        // Each binds to the payload commitment, proving WHAT was acted on without holding the payload.
499        assert!(audit.iter().all(|r| r.concerns_commitment(&C)));
500        // And carries a court-auditable attestation.
501        assert!(audit
502            .iter()
503            .all(|r| matches!(r.attestation, Attestation::Signature { .. })));
504    }
505
506    #[test]
507    fn a_conduct_record_can_carry_a_zk_attestation() {
508        // The ZK path: prove the agent held a valid credential for the committed payload, without the payload.
509        let r = ConductRecord {
510            attestation: Attestation::ZkProof {
511                proof_id: "zk:groth16:abc".into(),
512            },
513            ..conduct("k", "cc-1", "acted")
514        };
515        assert!(matches!(r.attestation, Attestation::ZkProof { .. }));
516        assert!(r.concerns_commitment(&C));
517    }
518
519    #[test]
520    fn commons_payload_survives_unilateral_deletion_but_is_useless_without_a_key() {
521        let mut p = EncryptedCommonsPayload::new(
522            C,
523            b"opaque-ciphertext".to_vec(),
524            vec![
525                "did:wf:person".into(),
526                "did:wf:storer-a".into(),
527                "did:wf:storer-b".into(),
528            ],
529        );
530        assert!(p.is_durable(), "replicated across the commons");
531        assert_eq!(p.replication(), 3);
532
533        // A hostile party (or the agent) deleting THEIR copy does not erase the payload.
534        assert!(
535            p.drop_storer("did:wf:storer-a"),
536            "copies remain after one deletion"
537        );
538        assert_eq!(p.replication(), 2);
539        assert!(p.drop_storer("did:wf:person"), "still survives");
540
541        // Only when the last copy goes is the blob absent — the commons resists that.
542        assert!(!p.drop_storer("did:wf:storer-b"), "no copies left");
543        // The ciphertext, wherever held, is opaque — access is credential-gated, not storage-gated.
544        assert!(!p.ciphertext.is_empty());
545    }
546
547    #[test]
548    fn crypto_shredding_when_the_last_key_is_revoked_makes_it_unreadable_though_bytes_persist() {
549        let payload = EncryptedCommonsPayload::new(
550            C,
551            b"ct".to_vec(),
552            vec!["did:wf:person".into(), "did:wf:archive".into()],
553        );
554        let mut c = cred(); // the only credential granting a key to C
555                            // While the credential is live, the payload is not shredded (a key exists).
556        assert!(!is_crypto_shredded(
557            &payload,
558            std::slice::from_ref(&c),
559            1_100
560        ));
561        // Destroy the key (revoke) — no credential grants a key to C now → crypto-shredded.
562        c.revoke(1_200);
563        assert!(
564            is_crypto_shredded(&payload, std::slice::from_ref(&c), 1_300),
565            "no key anywhere → permanently unreadable, even though the commons bytes survive"
566        );
567        // The bytes are still replicated (not chased down) — erasure was by key-destruction.
568        assert!(payload.is_durable());
569    }
570
571    fn party(did: &str, role: &str) -> Party {
572        Party {
573            did: did.into(),
574            role: role.into(),
575        }
576    }
577
578    #[test]
579    fn a_court_or_authority_can_hold_a_credential() {
580        // A court credential (e.g. to support proceedings / the audit case).
581        let c = cred().with_authority(CredentialAuthority::Court {
582            order_ref: "order:2026-42".into(),
583        });
584        assert!(matches!(c.authority, CredentialAuthority::Court { .. }));
585        // Single authorization by default → the holder can access while active.
586        assert!(c.payload_accessible(1_100));
587
588        let c2 = cred().with_authority(CredentialAuthority::Authority {
589            authority_did: "did:wf:child-protection".into(),
590            instrument_ref: "mandate:7".into(),
591        });
592        assert!(matches!(
593            c2.authority,
594            CredentialAuthority::Authority { .. }
595        ));
596    }
597
598    #[test]
599    fn multisig_requires_party_instigation_and_threshold_signatures() {
600        let parties = vec![
601            party("did:wf:person", "subject"),
602            party("did:wf:advocate", "advocate"),
603            party("did:wf:court", "court"),
604        ];
605        let c = cred().requiring_multisig(parties, 2);
606
607        // Instigated by a participating party + 2 party signatures → authorised; exercise yields the key.
608        let ok = ExerciseRequest::new(
609            "did:wf:person",
610            vec!["did:wf:person".into(), "did:wf:advocate".into()],
611        );
612        assert!(c.can_exercise(&ok, 1_100));
613        assert!(
614            c.exercise(&ok, 1_100).is_some(),
615            "authorised exercise yields the key"
616        );
617
618        // Below threshold (only 1 party signature) → not authorised.
619        let too_few = ExerciseRequest::new("did:wf:person", vec!["did:wf:person".into()]);
620        assert!(!c.can_exercise(&too_few, 1_100));
621        assert!(c.exercise(&too_few, 1_100).is_none());
622    }
623
624    #[test]
625    fn an_authority_cannot_act_unilaterally_under_multisig() {
626        // A court holds a multi-sig credential. It CANNOT exercise it without a participating party
627        // instigating — this is "unable to act without instigation of one of the participating parties".
628        let parties = vec![
629            party("did:wf:person", "subject"),
630            party("did:wf:guardian", "guardian"),
631        ];
632        let c = cred()
633            .with_authority(CredentialAuthority::Court {
634                order_ref: "order:9".into(),
635            })
636            .requiring_multisig(parties, 1);
637
638        // The court (NOT a participating party) tries to instigate alone → refused, even with a signature
639        // it collected, because the instigator is not a participating party.
640        let court_alone = ExerciseRequest::new("did:wf:court", vec!["did:wf:court".into()]);
641        assert!(
642            !c.can_exercise(&court_alone, 1_100),
643            "an outside authority cannot act unilaterally"
644        );
645        assert!(c.exercise(&court_alone, 1_100).is_none());
646
647        // But once a participating party (the guardian) instigates and signs, the threshold is met.
648        let party_instigated =
649            ExerciseRequest::new("did:wf:guardian", vec!["did:wf:guardian".into()]);
650        assert!(
651            c.can_exercise(&party_instigated, 1_100),
652            "a participating party's instigation authorises it"
653        );
654    }
655
656    #[test]
657    fn revocation_defeats_even_an_authorised_multisig_exercise() {
658        let parties = vec![
659            party("did:wf:person", "subject"),
660            party("did:wf:advocate", "advocate"),
661        ];
662        let mut c = cred().requiring_multisig(parties, 2);
663        let req = ExerciseRequest::new(
664            "did:wf:person",
665            vec!["did:wf:person".into(), "did:wf:advocate".into()],
666        );
667        assert!(c.exercise(&req, 1_100).is_some());
668        c.revoke(1_200);
669        // Even a fully-signed, party-instigated exercise gets no key once revoked — the key is gone.
670        assert!(
671            c.exercise(&req, 1_300).is_none(),
672            "revocation (key destroyed) beats authorisation"
673        );
674    }
675
676    #[test]
677    fn serde_round_trips() {
678        let c = cred();
679        let back: ConsentCredential =
680            serde_json::from_str(&serde_json::to_string(&c).unwrap()).unwrap();
681        assert_eq!(c, back);
682        let r = conduct("k", "cc-1", "acted");
683        let back_r: ConductRecord =
684            serde_json::from_str(&serde_json::to_string(&r).unwrap()).unwrap();
685        assert_eq!(r, back_r);
686    }
687}