Skip to main content

igc_net/governance/
workflow.rs

1use crate::DidKey;
2use crate::PilotIdentity;
3
4use super::lookup::GovernanceLookup;
5use super::record::{PilotAuthDidRecord, PilotAuthDidRecordError};
6use super::selection::{GovernanceSelectionError, select_pilot_auth_did_state};
7use super::state::PilotAuthDidStateStatus;
8use super::store::GovernanceStoreError;
9
10#[derive(Debug, thiserror::Error)]
11pub enum PilotAuthDidWorkflowError {
12    #[error("governance: {0}")]
13    Governance(#[from] GovernanceStoreError),
14    #[error("record: {0}")]
15    Record(#[from] PilotAuthDidRecordError),
16    #[error("selection: {0}")]
17    Selection(#[from] GovernanceSelectionError),
18    #[error(
19        "cannot issue initial pilot-auth-did-record because authoritative state already exists"
20    )]
21    InitialRecordAlreadyExists,
22    #[error("cannot rotate pilot_auth_did without an authoritative local governance state")]
23    MissingAuthoritativeState,
24    #[error("cannot rotate pilot_auth_did from tentative governance state")]
25    TentativeGovernanceState,
26    #[error(
27        "local active pilot_auth_did {local} does not match authoritative governance DID {authoritative}"
28    )]
29    ActiveDidMismatch {
30        authoritative: DidKey,
31        local: DidKey,
32    },
33    #[error("rotation candidate reuses the current authoritative pilot_auth_did")]
34    RotationDidUnchanged,
35    #[error("rotation candidate did not become authoritative under local validation")]
36    CandidateNotAuthoritative,
37}
38
39pub fn issue_initial_pilot_auth_did_record(
40    governance: &impl GovernanceLookup,
41    pilot_identity: &PilotIdentity,
42    created_at: impl Into<String>,
43) -> Result<PilotAuthDidRecord, PilotAuthDidWorkflowError> {
44    let state = governance.resolve_pilot_auth_did_state(&pilot_identity.pilot_id())?;
45    match state.status() {
46        PilotAuthDidStateStatus::Absent => {}
47        PilotAuthDidStateStatus::Tentative => {
48            return Err(PilotAuthDidWorkflowError::TentativeGovernanceState);
49        }
50        PilotAuthDidStateStatus::Authoritative => {
51            return Err(PilotAuthDidWorkflowError::InitialRecordAlreadyExists);
52        }
53    }
54
55    let record = PilotAuthDidRecord::issue(
56        &pilot_identity.pilot_id_secret_key(),
57        pilot_identity.active_pilot_auth_did(),
58        None,
59        created_at,
60    )?;
61    let prospective =
62        select_pilot_auth_did_state(&pilot_identity.pilot_id(), std::slice::from_ref(&record))?;
63    match prospective.authoritative.as_ref() {
64        Some(candidate) if candidate.record_id == record.record_id => {}
65        _ => {
66            return Err(PilotAuthDidWorkflowError::CandidateNotAuthoritative);
67        }
68    }
69
70    Ok(record)
71}
72
73pub fn rotate_pilot_auth_did_record(
74    governance: &impl GovernanceLookup,
75    current_pilot_identity: &PilotIdentity,
76    next_active_pilot_auth_secret_key: &iroh::SecretKey,
77    created_at: impl Into<String>,
78) -> Result<PilotAuthDidRecord, PilotAuthDidWorkflowError> {
79    let state = governance.resolve_pilot_auth_did_state(&current_pilot_identity.pilot_id())?;
80    let authoritative = match state.status() {
81        PilotAuthDidStateStatus::Absent => {
82            return Err(PilotAuthDidWorkflowError::MissingAuthoritativeState);
83        }
84        PilotAuthDidStateStatus::Tentative => {
85            return Err(PilotAuthDidWorkflowError::TentativeGovernanceState);
86        }
87        PilotAuthDidStateStatus::Authoritative => state
88            .authoritative
89            .clone()
90            .expect("authoritative status must include authoritative record"),
91    };
92
93    let local_active_did = current_pilot_identity.active_pilot_auth_did();
94    if authoritative.pilot_auth_did != local_active_did {
95        return Err(PilotAuthDidWorkflowError::ActiveDidMismatch {
96            authoritative: authoritative.pilot_auth_did,
97            local: local_active_did,
98        });
99    }
100
101    let next_active_pilot_auth_did =
102        DidKey::from_public_key(next_active_pilot_auth_secret_key.public());
103    if next_active_pilot_auth_did == authoritative.pilot_auth_did {
104        return Err(PilotAuthDidWorkflowError::RotationDidUnchanged);
105    }
106    let record = PilotAuthDidRecord::issue(
107        &current_pilot_identity.pilot_id_secret_key(),
108        next_active_pilot_auth_did,
109        Some(authoritative.record_id.clone()),
110        created_at,
111    )?;
112
113    let mut records = governance.load_pilot_auth_did_records(&current_pilot_identity.pilot_id())?;
114    records.push(record.clone());
115    let prospective = select_pilot_auth_did_state(&current_pilot_identity.pilot_id(), &records)?;
116    match prospective.authoritative.as_ref() {
117        Some(candidate) if candidate.record_id == record.record_id => {}
118        _ => {
119            return Err(PilotAuthDidWorkflowError::CandidateNotAuthoritative);
120        }
121    }
122
123    Ok(record)
124}
125
126#[cfg(test)]
127mod tests {
128    use crate::PilotKeyStore;
129    use crate::governance::store::GovernanceStore;
130    use crate::identity::DidKey;
131
132    use super::*;
133
134    fn deterministic_secret_key(byte: u8) -> iroh::SecretKey {
135        iroh::SecretKey::from_bytes(&[byte; 32])
136    }
137
138    fn temp_governance_store() -> (GovernanceStore, tempfile::TempDir) {
139        let dir = tempfile::tempdir().unwrap();
140        let store = GovernanceStore::for_data_dir(dir.path());
141        store.init().unwrap();
142        (store, dir)
143    }
144
145    fn temp_pilot_identity(
146        byte: u8,
147    ) -> (
148        PilotKeyStore,
149        PilotIdentity,
150        tempfile::TempDir,
151        iroh::SecretKey,
152    ) {
153        let dir = tempfile::tempdir().unwrap();
154        let key_store = PilotKeyStore::open(dir.path().join("pilot"));
155        key_store.init().unwrap();
156        let node_secret = deterministic_secret_key(byte);
157        let identity = key_store.generate(&node_secret).unwrap();
158        (key_store, identity, dir, node_secret)
159    }
160
161    #[test]
162    fn initial_issuance_requires_absent_state() {
163        let (governance, _dir) = temp_governance_store();
164        let (_keys, identity, _keys_dir, _node_secret) = temp_pilot_identity(201);
165
166        let record =
167            issue_initial_pilot_auth_did_record(&governance, &identity, "2026-05-01T09:14:00Z")
168                .unwrap();
169        assert_eq!(record.supersedes, None);
170        assert_eq!(record.pilot_auth_did, identity.active_pilot_auth_did());
171    }
172
173    #[test]
174    fn initial_issuance_rejects_existing_authority() {
175        let (governance, _dir) = temp_governance_store();
176        let (_keys, identity, _keys_dir, _node_secret) = temp_pilot_identity(202);
177        let existing = PilotAuthDidRecord::issue(
178            &identity.pilot_id_secret_key(),
179            identity.active_pilot_auth_did(),
180            None,
181            "2026-05-01T09:14:00Z",
182        )
183        .unwrap();
184        governance.persist_pilot_auth_did_record(&existing).unwrap();
185
186        let err =
187            issue_initial_pilot_auth_did_record(&governance, &identity, "2026-05-01T09:15:00Z")
188                .unwrap_err();
189        assert!(matches!(
190            err,
191            PilotAuthDidWorkflowError::InitialRecordAlreadyExists
192        ));
193    }
194
195    #[test]
196    fn rotation_requires_authoritative_state_match() {
197        let (governance, _dir) = temp_governance_store();
198        let (_keys, identity, _keys_dir, _node_secret) = temp_pilot_identity(203);
199        let next = deterministic_secret_key(204);
200
201        let err =
202            rotate_pilot_auth_did_record(&governance, &identity, &next, "2026-05-01T10:14:00Z")
203                .unwrap_err();
204        assert!(matches!(
205            err,
206            PilotAuthDidWorkflowError::MissingAuthoritativeState
207        ));
208    }
209
210    #[test]
211    fn rotation_refuses_tentative_local_state() {
212        let (governance, _dir) = temp_governance_store();
213        let (_keys, identity, _keys_dir, _node_secret) = temp_pilot_identity(205);
214        let incomplete = PilotAuthDidRecord::issue(
215            &identity.pilot_id_secret_key(),
216            identity.active_pilot_auth_did(),
217            Some(crate::Blake3Hex::parse("d".repeat(64)).unwrap()),
218            "2026-05-01T09:14:00Z",
219        )
220        .unwrap();
221        governance
222            .persist_pilot_auth_did_record(&incomplete)
223            .unwrap();
224
225        let err = rotate_pilot_auth_did_record(
226            &governance,
227            &identity,
228            &deterministic_secret_key(206),
229            "2026-05-01T10:14:00Z",
230        )
231        .unwrap_err();
232        assert!(matches!(
233            err,
234            PilotAuthDidWorkflowError::TentativeGovernanceState
235        ));
236    }
237
238    #[test]
239    fn rotation_candidate_becomes_authoritative() {
240        let (governance, _dir) = temp_governance_store();
241        let (_keys, identity, _keys_dir, _node_secret) = temp_pilot_identity(207);
242        let initial = PilotAuthDidRecord::issue(
243            &identity.pilot_id_secret_key(),
244            identity.active_pilot_auth_did(),
245            None,
246            "2026-05-01T09:14:00Z",
247        )
248        .unwrap();
249        governance.persist_pilot_auth_did_record(&initial).unwrap();
250
251        let next = deterministic_secret_key(208);
252        let rotated =
253            rotate_pilot_auth_did_record(&governance, &identity, &next, "2026-05-01T10:14:00Z")
254                .unwrap();
255
256        assert_eq!(rotated.supersedes, Some(initial.record_id));
257        assert_eq!(
258            rotated.pilot_auth_did,
259            DidKey::from_public_key(next.public())
260        );
261    }
262
263    #[test]
264    fn rotation_rejects_unchanged_did() {
265        let (governance, _dir) = temp_governance_store();
266        let (_keys, identity, _keys_dir, _node_secret) = temp_pilot_identity(210);
267        let initial = PilotAuthDidRecord::issue(
268            &identity.pilot_id_secret_key(),
269            identity.active_pilot_auth_did(),
270            None,
271            "2026-05-01T09:14:00Z",
272        )
273        .unwrap();
274        governance.persist_pilot_auth_did_record(&initial).unwrap();
275
276        let err = rotate_pilot_auth_did_record(
277            &governance,
278            &identity,
279            &identity.active_pilot_auth_secret_key(),
280            "2026-05-01T10:14:00Z",
281        )
282        .unwrap_err();
283        assert!(matches!(
284            err,
285            PilotAuthDidWorkflowError::RotationDidUnchanged
286        ));
287    }
288
289    #[test]
290    fn key_store_can_replace_active_pilot_auth_and_archive_previous_key() {
291        let (key_store, identity, _dir, node_secret) = temp_pilot_identity(209);
292        let next = key_store
293            .generate_next_active_pilot_auth_secret_key(&node_secret)
294            .unwrap();
295        let previous_public_key_hex = identity.active_pilot_auth_public_key_hex();
296
297        let updated = key_store
298            .replace_active_pilot_auth(&node_secret, &next)
299            .unwrap();
300
301        assert_eq!(updated.pilot_id(), identity.pilot_id());
302        assert_eq!(
303            updated.active_pilot_auth_public_key_hex(),
304            next.public().to_string()
305        );
306        let status = key_store.inspect().unwrap();
307        assert_eq!(status.archived_key_count, 1);
308        assert!(
309            status
310                .archive_dir
311                .join(format!("{previous_public_key_hex}.json"))
312                .exists()
313        );
314    }
315}