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}