Skip to main content

clawft_kernel/
chain_anchor_parent.rs

1//! `ChainAnchorBackend::Parent`: a project kernel anchors its chain head to
2//! the user daemon (ADR-103 A7, Phase 2 package D).
3//!
4//! [`ParentAnchor`] builds a [`ProjectAnchorStmt`] for the project chain
5//! head, signs it with the project key and hands it to a [`ParentTransport`]
6//! (`project.anchor.submit` on the user daemon; the real link is wired by
7//! packages G/H, tests stub it). On acceptance it appends
8//! `project.anchored` to the project chain (the two-way link). Frequency
9//! (300 s / 100 events, plus one on graceful shutdown) is the job of the
10//! existing [`AnchoringController`](super::AnchoringController) wrapped
11//! around this backend; [`ParentAnchor::anchor_head`] is the forced path
12//! for shutdown.
13//!
14//! Crash and outage rules:
15//! * The statement is written to `pending_path` (0600, atomic replace)
16//!   BEFORE it is sent. A kill between the chain append and the anchor, or
17//!   between the user daemon's acceptance and our `project.anchored`, leaves
18//!   the pending file; [`ParentAnchor::retry_pending`] resends it and the
19//!   user daemon answers an identical statement idempotently.
20//! * Only the latest pending statement is kept and replayed: a later head
21//!   covers earlier ones. `seq` increments per ACCEPTED statement, so every
22//!   pending statement of an outage carries the same `seq`.
23//! * Retries back off (5 s doubling to 300 s). A rejection that carries the
24//!   parent's last accepted statement (lost acknowledgement) is adopted when
25//!   it verifies under our own key, then the anchor is rebuilt on top of it.
26//! * The last accepted statement is recovered from `project.anchored`
27//!   events (each re-verified under the project key) at construction, so a
28//!   restart continues the `seq` / `prev_anchor` chain.
29//!
30//! Recovery trusts our own chain: a `project.anchored` event already on the
31//! hash-chained project chain is accepted without re-verifying its signature
32//! (only this process appends under that reserved source). So after
33//! `project.rekey` and a restart the statements signed by the replaced key
34//! are still the baseline, and the sequence continues at N + 1 under the new
35//! key.
36//!
37//! Adoption (a lost acknowledgement) takes the parent's last statement only
38//! when it verifies under the CURRENT key, is exactly the next one
39//! (`seq == last + 1` and `prev_anchor == hash(last)`; with nothing recorded
40//! only `seq == 1` and no `prev_anchor`), and OUR chain really has
41//! `head_hash` at `head_seq`: a parent cannot make us record a head we never
42//! produced. The `key_history` the parent sends is diagnostic only and never
43//! enters the trust path. The acknowledgement ([`AnchorAck`]) is
44//! authenticated by the transport only (the local unix socket to the user
45//! daemon); nothing signs it.
46//!
47//! Transport contract: [`ParentTransport::submit`] must return within a
48//! bounded time. It is run on a worker thread (one at a time: while an
49//! abandoned one is still running a new submission fails as unreachable)
50//! and abandoned after
51//! [`ParentAnchorConfig::submit_timeout_secs`] (default 10 s, also for the
52//! forced shutdown anchor), counting as unreachable; a late success of an
53//! abandoned call is a lost acknowledgement and is handled as such. No lock
54//! that readers use is held while a submission is in flight.
55//!
56//! The parent lost its state (no record, our `seq` is ahead): the parent
57//! answers `anchor_seq` with no `last`; nothing is adopted and the failure
58//! shows in [`ParentAnchor::last_error`]. A rewind of the anchor chain is an
59//! owner decision. Restore `<manifests>/<id>.anchor.json` on the user side
60//! from the `statement`, `user_seq` and `user_event_hash` of our last
61//! `project.anchored` event (that is exactly the file's content), then
62//! restart the user daemon so it re-appends the user-chain event.
63//!
64//! Honest limit: the user daemon attests "this key claimed head X at time
65//! T"; it cannot verify X without subscribing to the project chain.
66
67use std::path::PathBuf;
68use std::sync::atomic::{AtomicUsize, Ordering};
69use std::sync::{Arc, Mutex, mpsc};
70
71use chrono::{DateTime, Duration, Utc};
72use clawft_types::project::canon::{hex_decode, hex_encode};
73use clawft_types::project::cert::{ProjectAnchorStmt, key_id, ts};
74use ed25519_dalek::SigningKey;
75use serde_json::{Value, json};
76
77use super::ANCHOR_SOURCE;
78use crate::chain::{AnchorReceipt, ChainAnchor, ChainEvent, ChainManager};
79
80#[path = "chain_anchor_parent_types.rs"]
81mod types;
82pub use types::*;
83
84#[derive(Debug, Clone)]
85struct Accepted {
86    stmt: ProjectAnchorStmt,
87    ack: AnchorAck,
88}
89
90#[derive(Default)]
91struct State {
92    last: Option<Accepted>,
93    failures: u32,
94    retry_at: Option<DateTime<Utc>>,
95    last_error: Option<String>,
96}
97
98/// The `Parent` anchor backend.
99pub struct ParentAnchor {
100    chain: Arc<ChainManager>,
101    key: SigningKey,
102    project_id: String,
103    cert_serial: Mutex<u64>,
104    transport: Arc<dyn ParentTransport>,
105    pending_path: PathBuf,
106    cfg: ParentAnchorConfig,
107    /// Serialises anchor operations; held across a submission.
108    op: Mutex<()>,
109    /// Short-lived: never held across a submission.
110    state: Mutex<State>,
111    /// Submission workers still running (abandoned ones included).
112    inflight: Arc<AtomicUsize>,
113}
114
115fn receipt_of(acc: &Accepted, now: DateTime<Utc>) -> AnchorReceipt {
116    AnchorReceipt {
117        hash: hex_decode::<32>(&acc.stmt.head_hash).unwrap_or([0u8; 32]),
118        tx_id: format!("parent-{}", acc.ack.user_seq),
119        anchored_at: now,
120    }
121}
122
123impl ParentAnchor {
124    /// Build over the project `chain`. Recovers the last accepted statement
125    /// (signed by the current key) from the chain and drops a pending
126    /// statement that is already covered.
127    pub fn new(
128        chain: Arc<ChainManager>,
129        key: SigningKey,
130        project_id: impl Into<String>,
131        cert_serial: u64,
132        transport: Arc<dyn ParentTransport>,
133        pending_path: impl Into<PathBuf>,
134        cfg: ParentAnchorConfig,
135    ) -> Self {
136        let this = Self {
137            chain,
138            key,
139            project_id: project_id.into(),
140            cert_serial: Mutex::new(cert_serial),
141            transport,
142            pending_path: pending_path.into(),
143            cfg,
144            op: Mutex::new(()),
145            state: Mutex::new(State::default()),
146            inflight: Arc::new(AtomicUsize::new(0)),
147        };
148        {
149            let mut st = this.state.lock().unwrap();
150            st.last = this.recover();
151            if this.read_pending().is_some_and(|p| st.last.as_ref().is_some_and(|l| p.seq <= l.stmt.seq)) {
152                let _ = std::fs::remove_file(&this.pending_path);
153            }
154        }
155        this
156    }
157
158    /// The certificate serial put into new statements (after a rekey).
159    pub fn set_cert_serial(&self, serial: u64) {
160        *self.cert_serial.lock().unwrap() = serial;
161    }
162
163    /// `(seq, statement hash)` of the last accepted statement.
164    pub fn last_accepted(&self) -> Option<(u64, String)> {
165        let st = self.state.lock().unwrap();
166        st.last.as_ref().map(|a| (a.stmt.seq, a.stmt.hash()))
167    }
168
169    /// The statement waiting for the parent, if any.
170    pub fn pending(&self) -> Option<ProjectAnchorStmt> {
171        self.read_pending()
172    }
173
174    /// Message of the most recent failed attempt, cleared on success.
175    pub fn last_error(&self) -> Option<String> {
176        self.state.lock().unwrap().last_error.clone()
177    }
178
179    fn pubkey(&self) -> [u8; 32] {
180        self.key.verifying_key().to_bytes()
181    }
182
183    /// The newest `project.anchored` event on our own chain.
184    fn recover(&self) -> Option<Accepted> {
185        let mut best: Option<Accepted> = None;
186        for e in self.chain.tail(0) {
187            if e.source != ANCHOR_SOURCE || e.kind != KIND_ANCHORED {
188                continue;
189            }
190            let Some(acc) = e.payload.as_ref().and_then(parse_anchored) else { continue };
191            if acc.stmt.project_id == self.project_id && best.as_ref().is_none_or(|b| acc.stmt.seq > b.stmt.seq) {
192                best = Some(acc);
193            }
194        }
195        best
196    }
197
198    fn head_event(&self) -> Result<ChainEvent, String> {
199        self.chain
200            .tail(1)
201            .into_iter()
202            .next_back()
203            .ok_or_else(|| "project chain is empty; nothing to anchor".to_string())
204    }
205
206    fn event_for(&self, hash: &[u8; 32]) -> Result<ChainEvent, String> {
207        let head = self.head_event()?;
208        if &head.hash == hash {
209            return Ok(head);
210        }
211        self.chain
212            .tail(usize::MAX)
213            .into_iter()
214            .rev()
215            .find(|e| &e.hash == hash)
216            .ok_or_else(|| "hash is not an event of the project chain".to_string())
217    }
218
219    /// Does our own chain have `hash` at `seq`?
220    fn has_head(&self, seq: u64, hash: &str) -> bool {
221        self.chain
222            .tail(0)
223            .iter()
224            .any(|e| e.sequence == seq && hex_encode(&e.hash) == hash)
225    }
226
227    /// Nothing new since the last accepted statement (own acknowledgement
228    /// events do not count).
229    fn covered(last: &Accepted, ev: &ChainEvent) -> bool {
230        ev.sequence <= last.stmt.head_seq
231            || (ev.sequence == last.stmt.head_seq + 1
232                && ev.source == ANCHOR_SOURCE
233                && ev.kind == KIND_ANCHORED)
234    }
235
236    fn build(&self, last: Option<&Accepted>, ev: &ChainEvent, now: DateTime<Utc>) -> ProjectAnchorStmt {
237        ProjectAnchorStmt {
238            project_id: self.project_id.clone(),
239            project_key_id: String::new(),
240            cert_serial: *self.cert_serial.lock().unwrap(),
241            seq: last.map_or(1, |l| l.stmt.seq + 1),
242            chain_id: u64::from(ev.chain_id),
243            head_hash: hex_encode(&ev.hash),
244            head_seq: ev.sequence,
245            rule_hash: hex_encode(&ev.rule_hash.unwrap_or([0u8; 32])),
246            at: ts(now),
247            prev_anchor: last.map(|l| l.stmt.hash()),
248            sig: String::new(),
249        }
250        .sign(&self.key)
251    }
252
253    fn read_pending(&self) -> Option<ProjectAnchorStmt> {
254        let text = std::fs::read_to_string(&self.pending_path).ok()?;
255        text.lines().rev().find(|l| !l.trim().is_empty()).and_then(|l| serde_json::from_str(l).ok())
256    }
257
258    fn write_pending(&self, stmt: &ProjectAnchorStmt) -> Result<(), String> {
259        let mut line = serde_json::to_vec(stmt).map_err(|e| e.to_string())?;
260        line.push(b'\n');
261        crate::project_identity::write_private_atomic(&self.pending_path, &line, false)
262            .map_err(|e| format!("write {}: {e}", self.pending_path.display()))
263    }
264
265    fn fail(&self, now: DateTime<Utc>, why: String) -> String {
266        let mut st = self.state.lock().unwrap();
267        st.failures = st.failures.saturating_add(1);
268        let shift = (st.failures - 1).min(20);
269        let secs = (self.cfg.backoff_base_secs << shift).min(self.cfg.backoff_max_secs);
270        st.retry_at = Some(now + Duration::seconds(secs));
271        st.last_error = Some(why.clone());
272        why
273    }
274
275    /// Append `project.anchored`, remember the statement, clear a covered
276    /// pending file. The append comes first: a kill after it leaves a
277    /// pending statement with `seq <= last`, which [`Self::new`] discards.
278    fn record(&self, stmt: ProjectAnchorStmt, ack: AnchorAck) -> Accepted {
279        self.chain.append(
280            ANCHOR_SOURCE,
281            KIND_ANCHORED,
282            Some(json!({
283                "statement": stmt,
284                "user_seq": ack.user_seq,
285                "user_event_hash": ack.user_event_hash,
286            })),
287        );
288        if self.read_pending().is_none_or(|p| p.seq <= stmt.seq) {
289            let _ = std::fs::remove_file(&self.pending_path);
290        }
291        let acc = Accepted { stmt, ack };
292        let mut st = self.state.lock().unwrap();
293        st.last = Some(acc.clone());
294        st.failures = 0;
295        st.retry_at = None;
296        st.last_error = None;
297        acc
298    }
299
300    /// Take the parent's last accepted statement as ours (see the module
301    /// docs for the exact conditions).
302    fn adopt(&self, last: (ProjectAnchorStmt, AnchorAck)) -> Result<Accepted, String> {
303        let (stmt, ack) = last;
304        let ours = self.state.lock().unwrap().last.clone();
305        if stmt.project_id != self.project_id {
306            return Err("statement is for another project".into());
307        }
308        let pk = self.pubkey();
309        if key_id(&pk) != stmt.project_key_id || stmt.verify(&pk).is_err() {
310            return Err("statement does not verify under the current project key".into());
311        }
312        let extends = match &ours {
313            Some(o) => stmt.seq == o.stmt.seq + 1 && stmt.prev_anchor.as_deref() == Some(o.stmt.hash().as_str()),
314            None => stmt.seq == 1 && stmt.prev_anchor.is_none(),
315        };
316        if !extends {
317            return Err("statement is not the next one after the recorded anchor chain".into());
318        }
319        if !self.has_head(stmt.head_seq, &stmt.head_hash) {
320            return Err(format!(
321                "our chain has no event {} at seq {}; refusing to record a head we did not produce",
322                stmt.head_hash, stmt.head_seq
323            ));
324        }
325        Ok(self.record(stmt, ack))
326    }
327
328    /// One bounded submission. The call runs on a worker thread and is
329    /// abandoned after the configured deadline; no lock is held meanwhile.
330    fn submit_bounded(&self, stmt: &ProjectAnchorStmt) -> Result<AnchorAck, AnchorSubmitError> {
331        if self.inflight.load(Ordering::SeqCst) > 0 {
332            return Err(AnchorSubmitError::Unreachable("previous submit still in flight".into()));
333        }
334        self.inflight.fetch_add(1, Ordering::SeqCst);
335        let (tx, rx) = mpsc::channel();
336        let (transport, s, inflight) = (self.transport.clone(), stmt.clone(), self.inflight.clone());
337        std::thread::spawn(move || {
338            let r = transport.submit(&s);
339            inflight.fetch_sub(1, Ordering::SeqCst);
340            let _ = tx.send(r);
341        });
342        rx.recv_timeout(std::time::Duration::from_secs(self.cfg.submit_timeout_secs))
343            .unwrap_or_else(|_| {
344                Err(AnchorSubmitError::Unreachable(format!(
345                    "no answer within {} s",
346                    self.cfg.submit_timeout_secs
347                )))
348            })
349    }
350
351    /// Caller holds `op`.
352    fn deliver(&self, mut stmt: ProjectAnchorStmt, now: DateTime<Utc>) -> Result<AnchorReceipt, String> {
353        for attempt in 0..2 {
354            match self.submit_bounded(&stmt) {
355                Ok(ack) => return Ok(receipt_of(&self.record(stmt, ack), now)),
356                Err(AnchorSubmitError::Unreachable(m)) => {
357                    return Err(self.fail(now, format!("parent unreachable: {m}")));
358                }
359                Err(AnchorSubmitError::Rejected { kind, message, last, .. }) => {
360                    if attempt == 0 && let Some(last) = last {
361                        match self.adopt(*last) {
362                            Ok(acc) => {
363                                let head = self.head_event()?;
364                                if Self::covered(&acc, &head) {
365                                    return Ok(receipt_of(&acc, now));
366                                }
367                                stmt = self.build(Some(&acc), &head, now);
368                                self.write_pending(&stmt)?;
369                                continue;
370                            }
371                            Err(why) => {
372                                return Err(self.fail(
373                                    now,
374                                    format!("parent rejected anchor ({kind}): {message}; not adopted: {why}"),
375                                ));
376                            }
377                        }
378                    }
379                    return Err(self.fail(now, format!("parent rejected anchor ({kind}): {message}")));
380                }
381            }
382        }
383        Err(self.fail(now, "parent rejected the rebuilt anchor".into()))
384    }
385
386    fn anchor_event(&self, ev: ChainEvent, now: DateTime<Utc>, force: bool) -> Result<AnchorReceipt, String> {
387        let _op = self.op.lock().map_err(|_| "parent anchor lock poisoned".to_string())?;
388        let (last, retry_at) = {
389            let st = self.state.lock().unwrap();
390            (st.last.clone(), st.retry_at)
391        };
392        if let Some(acc) = &last
393            && Self::covered(acc, &ev)
394        {
395            return Ok(receipt_of(acc, now));
396        }
397        let stmt = self.build(last.as_ref(), &ev, now);
398        self.write_pending(&stmt)?;
399        if !force && let Some(t) = retry_at && now < t {
400            return Err(format!("parent unreachable; next retry after {}", ts(t)));
401        }
402        self.deliver(stmt, now)
403    }
404
405    /// Anchor the current head now, ignoring the frequency policy but not
406    /// the retry backoff unless `force` (graceful shutdown passes `true`).
407    pub fn anchor_head_at(&self, now: DateTime<Utc>, force: bool) -> Result<AnchorReceipt, String> {
408        self.anchor_event(self.head_event()?, now, force)
409    }
410
411    /// [`Self::anchor_head_at`] for the wall clock, forced: the shutdown
412    /// anchor. Bounded by the submission deadline.
413    pub fn anchor_head(&self) -> Result<AnchorReceipt, String> {
414        self.anchor_head_at(Utc::now(), true)
415    }
416
417    /// Resend the latest pending statement exactly as written once its
418    /// backoff has elapsed. `Ok(None)`: nothing pending, or not due yet.
419    pub fn retry_pending_at(&self, now: DateTime<Utc>) -> Result<Option<AnchorReceipt>, String> {
420        let _op = self.op.lock().map_err(|_| "parent anchor lock poisoned".to_string())?;
421        let Some(stmt) = self.read_pending() else { return Ok(None) };
422        let (last, retry_at) = {
423            let st = self.state.lock().unwrap();
424            (st.last.clone(), st.retry_at)
425        };
426        if last.is_some_and(|l| stmt.seq <= l.stmt.seq) {
427            let _ = std::fs::remove_file(&self.pending_path);
428            return Ok(None);
429        }
430        if retry_at.is_some_and(|t| now < t) {
431            return Ok(None);
432        }
433        self.deliver(stmt, now).map(Some)
434    }
435
436    /// [`Self::retry_pending_at`] for the wall clock.
437    pub fn retry_pending(&self) -> Result<Option<AnchorReceipt>, String> {
438        self.retry_pending_at(Utc::now())
439    }
440}
441
442fn parse_anchored(p: &Value) -> Option<Accepted> {
443    let stmt = serde_json::from_value(p.get("statement")?.clone()).ok()?;
444    Some(Accepted {
445        stmt,
446        ack: AnchorAck {
447            user_seq: p.get("user_seq")?.as_u64()?,
448            user_event_hash: p.get("user_event_hash")?.as_str()?.to_owned(),
449        },
450    })
451}
452
453impl ChainAnchor for ParentAnchor {
454    fn anchor(&self, hash: &[u8; 32]) -> Result<AnchorReceipt, String> {
455        let now = Utc::now();
456        self.anchor_event(self.event_for(hash)?, now, false)
457    }
458
459    fn verify(&self, receipt: &AnchorReceipt) -> Result<bool, String> {
460        let want = hex_encode(&receipt.hash);
461        Ok(self.chain.tail(0).iter().any(|e| {
462            e.source == ANCHOR_SOURCE
463                && e.kind == KIND_ANCHORED
464                && e.payload.as_ref().and_then(parse_anchored).is_some_and(|a| {
465                    a.stmt.head_hash == want && format!("parent-{}", a.ack.user_seq) == receipt.tx_id
466                })
467        }))
468    }
469
470    fn backend_name(&self) -> &str {
471        "parent"
472    }
473}
474
475#[cfg(test)]
476#[path = "chain_anchor_parent_tests.rs"]
477mod tests;