Skip to main content

qualia_client_core/
dead_mans_switch.rs

1//! **Dead-man switch** — post-death (or believed-death) disposition of the principal's data, under
2//! **gamified validation rules**, enacted by the person's **chosen friends who hold the dataset**.
3//!
4//! The consideration (Timothy, 2026-07-06): if the principal (the person) is *considered dead*, their
5//! information may be made public, or become subject to other rules — the erasure-prevention / right-to-truth
6//! stance (a murdered person's record must not simply vanish), governed by the person's **prior
7//! self-definition** and kept **reversible** (see the post-death-continuity work).
8//!
9//! The trigger must **not** be one abusable "declare dead" button — that would let an attacker (or a
10//! betrayer) fire it falsely. So validation is **gamified**: a *rule set* of independent conditions that must
11//! all hold — a **liveness lapse** (no update / no "still here" signal from the principal for X time) **and**
12//! an **attestation threshold** (a quorum of the participating parties attesting no-contact / believed-dead /
13//! abandonment). And it is enacted by the **friends who store the encrypted dataset** (the
14//! `EncryptedCommonsPayload` storers): they hold the ciphertext *and* validate the trigger, so no single
15//! party — and no outside actor — can enact it alone.
16//!
17//! **Reversibility is central.** The principal showing up alive (`principal_alive`) resets the liveness
18//! signal and un-fires a not-yet-irreversible switch. (Honesty caveat, §9: once a [`Disposition::MakePublic`]
19//! has actually released keys to a durable commons it cannot be un-published — so *which* dispositions are
20//! reversible, and the grace/limits, are values calls for Timothy.)
21//!
22//! Domain model + invariants only; the actual **key-release** on enactment (publish the data key / issue
23//! [`ConsentCredential`](crate::consent_credential::ConsentCredential)s to the disposition parties) and the
24//! storage are the crypto/commons composition (coordinate).
25
26use serde::{Deserialize, Serialize};
27
28use crate::consent_credential::PayloadCommitment;
29
30/// A **liveness heartbeat** — the principal periodically signals "still here". Its lapse (no update for
31/// `lapse_after_secs`) is one of the trigger conditions; touching it (the person is alive) is the reversibility.
32#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
33pub struct Heartbeat {
34    pub last_seen_unix: u64,
35    /// Grace period (seconds): with no update for at least this long, the liveness signal is *lapsed*.
36    pub lapse_after_secs: u64,
37}
38
39impl Heartbeat {
40    pub fn new(last_seen_unix: u64, lapse_after_secs: u64) -> Self {
41        Self {
42            last_seen_unix,
43            lapse_after_secs,
44        }
45    }
46    /// Has the liveness signal lapsed at `now`?
47    pub fn is_lapsed(&self, now_unix: u64) -> bool {
48        now_unix.saturating_sub(self.last_seen_unix) >= self.lapse_after_secs
49    }
50    /// The principal is alive — reset the signal (reversibility).
51    pub fn touch(&mut self, now_unix: u64) {
52        self.last_seen_unix = now_unix;
53    }
54}
55
56/// What a party attests toward the trigger.
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
58#[serde(rename_all = "snake_case")]
59pub enum AttestationKind {
60    /// Has had no contact with the principal.
61    NoContact,
62    /// Believes the principal is dead.
63    BelievedDead,
64    /// Releases/abandons their hold (e.g. "the last lets go" — the abandonment condition).
65    Abandon,
66}
67
68/// One participating party's attestation toward enacting the switch.
69#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
70pub struct PartyAttestation {
71    pub party_did: String,
72    pub kind: AttestationKind,
73    pub time_unix: u64,
74}
75
76/// What happens to the data if the switch fires — the person's **prior self-definition** governs.
77#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
78#[serde(rename_all = "snake_case")]
79pub enum Disposition {
80    /// Make the payload public (release the data key widely). **Irreversible in effect** once released.
81    MakePublic,
82    /// Release access to specific parties (trustees, next-of-kin, a chosen representative) — reversible
83    /// (their credentials can be revoked).
84    ReleaseTo { parties: Vec<String> },
85    /// Enact other self-defined post-death rules (e.g. the digital-vellum representation), grounded-or-refused.
86    SelfDefinedRules { rules_ref: String },
87}
88
89/// The **gamified trigger rule** — the independent conditions that must *all* hold to fire, resisting a false
90/// trigger by any single party or an outside actor.
91#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
92pub struct TriggerRule {
93    /// Require the liveness heartbeat to have lapsed (no "still here" for the grace period).
94    pub require_heartbeat_lapsed: bool,
95    /// At least this many **distinct participating parties** must attest.
96    pub attestation_threshold: usize,
97    /// The participating parties — the friends/stewards who hold the dataset and validate the trigger.
98    pub parties: Vec<String>,
99}
100
101impl TriggerRule {
102    /// Is the rule satisfied at `now`, given the heartbeat + the attestations collected? Requires (heartbeat
103    /// lapsed, if required) AND (≥ `attestation_threshold` distinct *participating* parties have attested).
104    pub fn is_satisfied(
105        &self,
106        heartbeat: &Heartbeat,
107        attestations: &[PartyAttestation],
108        now_unix: u64,
109    ) -> bool {
110        if self.require_heartbeat_lapsed && !heartbeat.is_lapsed(now_unix) {
111            return false;
112        }
113        let is_party = |did: &str| self.parties.iter().any(|p| p == did);
114        let attesters: std::collections::BTreeSet<&str> = attestations
115            .iter()
116            .map(|a| a.party_did.as_str())
117            .filter(|d| is_party(d))
118            .collect();
119        attesters.len() >= self.attestation_threshold
120    }
121}
122
123/// A dead-man switch over one commons payload.
124#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
125pub struct DeadMansSwitch {
126    /// The dataset this governs (by commitment) — held by the friends (the commons storers).
127    pub payload_commitment: PayloadCommitment,
128    pub heartbeat: Heartbeat,
129    pub trigger: TriggerRule,
130    pub disposition: Disposition,
131    /// When the switch fired (enacted), if it has.
132    #[serde(default, skip_serializing_if = "Option::is_none")]
133    pub fired_unix: Option<u64>,
134}
135
136impl DeadMansSwitch {
137    /// Is the switch *currently triggerable* — the gamified rule satisfied and not already fired?
138    pub fn is_triggered(&self, attestations: &[PartyAttestation], now_unix: u64) -> bool {
139        self.fired_unix.is_none()
140            && self
141                .trigger
142                .is_satisfied(&self.heartbeat, attestations, now_unix)
143    }
144
145    /// The principal is alive — reset the liveness signal **and un-fire** a not-yet-enacted switch. This is
146    /// the reversibility: a person showing up defeats a premature or malicious trigger.
147    pub fn principal_alive(&mut self, now_unix: u64) {
148        self.heartbeat.touch(now_unix);
149        self.fired_unix = None;
150    }
151
152    /// **Enact** the switch if triggerable: record it fired and return the [`Disposition`] to apply (which
153    /// the caller carries out — publishing the key / issuing credentials to the disposition parties). Returns
154    /// `None` if the rule is not satisfied (or it already fired).
155    pub fn enact(
156        &mut self,
157        attestations: &[PartyAttestation],
158        now_unix: u64,
159    ) -> Option<&Disposition> {
160        if self.is_triggered(attestations, now_unix) {
161            self.fired_unix = Some(now_unix);
162            Some(&self.disposition)
163        } else {
164            None
165        }
166    }
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172
173    const C: PayloadCommitment = [11u8; 32];
174    const DAY: u64 = 24 * 60 * 60;
175
176    fn attest(did: &str, kind: AttestationKind) -> PartyAttestation {
177        PartyAttestation {
178            party_did: did.into(),
179            kind,
180            time_unix: 0,
181        }
182    }
183
184    /// A switch requiring: heartbeat lapsed after 90 days AND 2-of-3 friends attesting.
185    fn switch() -> DeadMansSwitch {
186        DeadMansSwitch {
187            payload_commitment: C,
188            heartbeat: Heartbeat::new(1_000_000, 90 * DAY),
189            trigger: TriggerRule {
190                require_heartbeat_lapsed: true,
191                attestation_threshold: 2,
192                parties: vec![
193                    "did:wf:alice".into(),
194                    "did:wf:bob".into(),
195                    "did:wf:carol".into(),
196                ],
197            },
198            disposition: Disposition::MakePublic,
199            fired_unix: None,
200        }
201    }
202
203    #[test]
204    fn does_not_fire_while_the_principal_is_alive_even_if_friends_attest() {
205        let s = switch();
206        let now = 1_000_000 + 10 * DAY; // heartbeat fresh (10 days < 90)
207        let attests = vec![
208            attest("did:wf:alice", AttestationKind::BelievedDead),
209            attest("did:wf:bob", AttestationKind::NoContact),
210        ];
211        assert!(
212            !s.is_triggered(&attests, now),
213            "fresh liveness signal defeats the trigger"
214        );
215    }
216
217    #[test]
218    fn does_not_fire_on_one_partys_say_so_below_threshold() {
219        let s = switch();
220        let now = 1_000_000 + 200 * DAY; // heartbeat lapsed
221        let one = vec![attest("did:wf:alice", AttestationKind::BelievedDead)];
222        assert!(
223            !s.is_triggered(&one, now),
224            "a single party cannot enact it (resists false trigger)"
225        );
226    }
227
228    #[test]
229    fn fires_only_when_liveness_lapsed_and_the_quorum_attests() {
230        let mut s = switch();
231        let now = 1_000_000 + 200 * DAY; // lapsed
232        let quorum = vec![
233            attest("did:wf:alice", AttestationKind::BelievedDead),
234            attest("did:wf:bob", AttestationKind::NoContact),
235        ];
236        assert!(s.is_triggered(&quorum, now));
237        // Enact → returns the disposition, records fired.
238        assert_eq!(s.enact(&quorum, now), Some(&Disposition::MakePublic));
239        assert!(s.fired_unix.is_some());
240        // Idempotent — does not re-fire.
241        assert!(s.enact(&quorum, now + DAY).is_none());
242    }
243
244    #[test]
245    fn non_party_attestations_do_not_count_toward_the_quorum() {
246        let s = switch();
247        let now = 1_000_000 + 200 * DAY;
248        // An outsider + one real party = still below the 2-party threshold.
249        let mixed = vec![
250            attest("did:wf:stranger", AttestationKind::BelievedDead),
251            attest("did:wf:alice", AttestationKind::BelievedDead),
252        ];
253        assert!(
254            !s.is_triggered(&mixed, now),
255            "only participating parties count"
256        );
257    }
258
259    #[test]
260    fn reversibility_the_principal_returning_alive_defeats_and_unfires_it() {
261        let mut s = switch();
262        let now = 1_000_000 + 200 * DAY;
263        let quorum = vec![
264            attest("did:wf:alice", AttestationKind::BelievedDead),
265            attest("did:wf:bob", AttestationKind::NoContact),
266        ];
267        assert!(s.enact(&quorum, now).is_some(), "fired");
268        // The person shows up alive → reset + un-fire (reversibility).
269        s.principal_alive(now + DAY);
270        assert!(s.fired_unix.is_none(), "un-fired");
271        assert!(
272            !s.is_triggered(&quorum, now + 2 * DAY),
273            "fresh liveness defeats the same attestations"
274        );
275    }
276
277    #[test]
278    fn abandonment_by_all_parties_is_a_configurable_rule() {
279        // A rule where the condition is that ALL parties abandon (attestation_threshold == parties.len()),
280        // heartbeat not required — "when the last lets go".
281        let mut s = switch();
282        s.trigger.require_heartbeat_lapsed = false;
283        s.trigger.attestation_threshold = 3; // all three
284        s.disposition = Disposition::SelfDefinedRules {
285            rules_ref: "vellum:self-defined".into(),
286        };
287        let all = vec![
288            attest("did:wf:alice", AttestationKind::Abandon),
289            attest("did:wf:bob", AttestationKind::Abandon),
290            attest("did:wf:carol", AttestationKind::Abandon),
291        ];
292        assert!(
293            s.is_triggered(&all, 0),
294            "all parties abandoning satisfies this rule"
295        );
296        // Two-of-three abandoning does not.
297        assert!(!s.is_triggered(&all[..2], 0));
298    }
299
300    #[test]
301    fn serde_round_trips() {
302        let s = switch();
303        let back: DeadMansSwitch =
304            serde_json::from_str(&serde_json::to_string(&s).unwrap()).unwrap();
305        assert_eq!(s, back);
306    }
307}