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}