Skip to main content

gatehouse_runtime_protocol/lifecycle/
journal.rs

1//! Lifecycle evidence journal. No process execution or old-lease conversion.
2//! Only the private durable store may publish a record or issue dispatch tokens.
3use super::{
4    state::{Event, Phase, State},
5    wire::{self, Binding, ContainerState, Operation, Outcome, Request, Response, Target},
6};
7use crate::{Invalid, Result};
8use serde::{Deserialize, Serialize};
9
10#[cfg(unix)]
11mod storage;
12#[cfg(unix)]
13pub use storage::{
14    Dispatch, Journal, OperatorRecovery, Reservation, ResolutionDispatch, ServiceSlot,
15};
16
17mod resolution;
18pub use resolution::CreatePlan;
19use resolution::ResolutionProof;
20
21const SCHEMA: &str = "gatehouse-lifecycle-lease/v1";
22const MAX_ATTEMPTS: usize = 128;
23
24/// Supplied by the trusted Core command path, never parsed from adapter output.
25/// This expresses policy; it does not authenticate the human/operator itself.
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
27#[serde(rename_all = "kebab-case")]
28pub enum Purpose {
29    Service,
30    ServiceCleanup,
31    OperatorCleanup,
32    Reconcile,
33}
34
35#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
36#[serde(deny_unknown_fields)]
37struct Pending {
38    request: Request,
39    purpose: Purpose,
40}
41
42#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
43#[serde(deny_unknown_fields)]
44struct Attempt {
45    request: Request,
46    purpose: Purpose,
47    // None includes abandoned/unknown outcomes. Never infer no side effect.
48    response: Option<Response>,
49}
50
51#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
52#[serde(rename_all = "kebab-case")]
53enum AbsenceReason {
54    OperatorObservedAbsence,
55    PostRemoveAbsence,
56}
57#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
58#[serde(deny_unknown_fields)]
59struct AbsenceProof {
60    reason: AbsenceReason,
61    remove_request_sha256: Option<String>,
62    status_request_sha256: String,
63    response: Response,
64}
65#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
66#[serde(deny_unknown_fields)]
67struct Record {
68    schema: String,
69    create: Request,
70    resolution: Option<ResolutionProof>,
71    state: State,
72    revision: u64,
73    pending: Option<Pending>,
74    attempts: Vec<Attempt>,
75    absence: Option<AbsenceProof>,
76    recoveries: Vec<u64>,
77}
78fn same_authority(a: &Binding, b: &Binding) -> bool {
79    let mut a = a.clone();
80    a.invocation = b.invocation.clone();
81    a.operation_key = b.operation_key.clone();
82    a.generation = b.generation;
83    a == *b
84}
85impl Record {
86    fn new(create: &Request) -> Result<Self> {
87        Self::new_resolved(create, None)
88    }
89    fn new_resolved(create: &Request, resolution: Option<ResolutionProof>) -> Result<Self> {
90        create.validate()?;
91        if !matches!(create.operation, Operation::Create { .. })
92            || create.binding.generation != if resolution.is_some() { 2 } else { 1 }
93        {
94            return Err(Invalid("journal requires first create generation"));
95        }
96        if let Some(proof) = &resolution {
97            proof.validate_create(create)?;
98        }
99        Ok(Self {
100            schema: SCHEMA.into(),
101            create: create.clone(),
102            resolution,
103            state: State::pending_create(),
104            revision: create.binding.generation,
105            pending: Some(Pending {
106                request: create.clone(),
107                purpose: Purpose::Service,
108            }),
109            attempts: vec![],
110            absence: None,
111            recoveries: vec![],
112        })
113    }
114    fn marker(&self) -> &str {
115        self.resolution
116            .as_ref()
117            .map(|p| p.request.binding.operation_key.as_str())
118            .unwrap_or(&self.create.binding.operation_key)
119    }
120    fn target(&self) -> Result<Target> {
121        let Operation::Create {
122            name,
123            labels,
124            container_port,
125            ..
126        } = &self.create.operation
127        else {
128            return Err(Invalid("invalid journal create"));
129        };
130        Ok(Target {
131            object_id: self
132                .state
133                .object_id()
134                .ok_or(Invalid("journal object unresolved"))?
135                .into(),
136            name: name.clone(),
137            labels: labels.clone(),
138            container_port: *container_port,
139        })
140    }
141    fn check_request(&self, r: &Request) -> Result<()> {
142        r.validate()?;
143        if !same_authority(&r.binding, &self.create.binding) {
144            return Err(Invalid("journal authority mismatch"));
145        }
146        let target = match &r.operation {
147            Operation::Status { target }
148            | Operation::Start { target }
149            | Operation::Stop { target, .. }
150            | Operation::Remove { target }
151            | Operation::HealthExec { target, .. } => target,
152            _ => return Err(Invalid("journal operation unsupported after creation")),
153        };
154        if *target != self.target()? {
155            return Err(Invalid("journal target mismatch"));
156        }
157        Ok(())
158    }
159    fn validate(&self) -> Result<()> {
160        if self.schema != SCHEMA || self.attempts.len() > MAX_ATTEMPTS {
161            return Err(Invalid("invalid lifecycle journal"));
162        }
163        self.create.validate()?;
164        self.state.validate()?;
165        if !matches!(self.create.operation, Operation::Create { .. })
166            || self.create.binding.generation != if self.resolution.is_some() { 2 } else { 1 }
167        {
168            return Err(Invalid("invalid journal origin"));
169        }
170        self.validate_history()?;
171        self.validate_absence()?;
172        self.replay()?;
173        Ok(())
174    }
175    fn validate_history(&self) -> Result<()> {
176        let base = self.create.binding.generation - 1;
177        let mut revision = base;
178        let mut nonces = std::collections::BTreeSet::new();
179        if let Some(proof) = &self.resolution {
180            proof.validate_create(&self.create)?;
181            nonces.insert(&proof.request.binding.invocation);
182        }
183        for a in &self.attempts {
184            if a.request.binding.generation != revision + 1
185                || !nonces.insert(&a.request.binding.invocation)
186            {
187                return Err(Invalid("journal generation or nonce replay"));
188            }
189            a.request.validate()?;
190            if revision == base {
191                if a.request != self.create || a.purpose != Purpose::Service {
192                    return Err(Invalid("journal creation history mismatch"));
193                }
194                match a.response.as_ref().map(|r| &r.outcome) {
195                    Some(Outcome::Created { object })
196                        if self.state.object_id() == Some(object.object_id.as_str()) => {}
197                    None | Some(Outcome::Unknown { .. } | Outcome::RejectedBeforeEffect { .. })
198                        if self.state.object_id().is_none() => {}
199                    _ => return Err(Invalid("journal object has no creation evidence")),
200                }
201            } else {
202                self.check_request(&a.request)?;
203            }
204            if !same_authority(&a.request.binding, &self.create.binding) {
205                return Err(Invalid("journal historical authority mismatch"));
206            }
207            if let Some(r) = &a.response {
208                r.validate(&a.request)?;
209            }
210            revision += 1;
211        }
212        revision = self.validate_pending(base, revision, &mut nonces)?;
213        if revision != self.revision {
214            return Err(Invalid("journal revision mismatch"));
215        }
216        Ok(())
217    }
218    fn validate_pending<'a>(
219        &'a self,
220        base: u64,
221        mut revision: u64,
222        nonces: &mut std::collections::BTreeSet<&'a String>,
223    ) -> Result<u64> {
224        if let Some(p) = &self.pending {
225            if p.request.binding.generation != revision + 1
226                || !nonces.insert(&p.request.binding.invocation)
227            {
228                return Err(Invalid("journal pending replay"));
229            }
230            p.request.validate()?;
231            if revision == base {
232                if p.request != self.create
233                    || p.purpose != Purpose::Service
234                    || self.state.phase() != Phase::PendingCreate
235                {
236                    return Err(Invalid("journal initial intent mismatch"));
237                }
238            } else {
239                self.check_request(&p.request)?;
240            }
241            if !same_authority(&p.request.binding, &self.create.binding) {
242                return Err(Invalid("journal pending authority mismatch"));
243            }
244            revision += 1;
245        }
246        Ok(revision)
247    }
248    fn validate_absence(&self) -> Result<()> {
249        if self.state.phase() == Phase::VerifiedAbsent {
250            let a = self
251                .attempts
252                .last()
253                .ok_or(Invalid("missing absence attempt"))?;
254            if a.purpose == Purpose::Service
255                || (a.purpose == Purpose::Reconcile
256                    && !self
257                        .attempts
258                        .iter()
259                        .any(|old| matches!(old.request.operation, Operation::Remove { .. })))
260                || self.pending.is_some()
261                || self.absence.as_ref().map(|p| &p.response) != a.response.as_ref()
262                || !matches!(
263                    self.absence.as_ref().map(|p| &p.response.outcome),
264                    Some(Outcome::Absent { .. })
265                )
266            {
267                return Err(Invalid("missing journal absence evidence"));
268            }
269        } else if self.absence.is_some() {
270            return Err(Invalid("premature absence evidence"));
271        }
272        Ok(())
273    }
274    fn replay(&self) -> Result<()> {
275        if self.recoveries.windows(2).any(|w| w[0] >= w[1])
276            || self.recoveries.iter().any(|g| {
277                *g < self.create.binding.generation
278                    || *g > self.attempts.len() as u64 + self.create.binding.generation - 1
279            })
280        {
281            return Err(Invalid("invalid journal recovery history"));
282        }
283        let mut replay = Self::new_resolved(&self.create, self.resolution.clone())?;
284        let mut fresh = false;
285        for (index, a) in self.attempts.iter().enumerate() {
286            if index > 0 {
287                replay = replay.begin_inner(&a.request, a.purpose, fresh)?;
288            }
289            let bytes = a
290                .response
291                .as_ref()
292                .map(|r| {
293                    super::canonical::encode(
294                        &serde_json::to_value(r).map_err(|_| Invalid("invalid stored response"))?,
295                    )
296                })
297                .transpose()?;
298            replay = replay.observe_inner(bytes.as_deref())?;
299            fresh = matches!(
300                a.response.as_ref().map(|r| (&r.operation, &r.outcome)),
301                Some((Operation::Status { .. }, Outcome::Status { .. }))
302            );
303            if self.recoveries.contains(&replay.revision) {
304                replay.state = replay.state.recover()?;
305                replay.recoveries.push(replay.revision);
306                fresh = false;
307            }
308        }
309        if !self.attempts.is_empty() {
310            if let Some(p) = &self.pending {
311                replay = replay.begin_inner(&p.request, p.purpose, fresh)?;
312            }
313        }
314        if replay != *self {
315            return Err(Invalid(
316                "journal state does not follow its evidence history",
317            ));
318        }
319        Ok(())
320    }
321    fn begin(&self, r: &Request, purpose: Purpose, fresh_status: bool) -> Result<Self> {
322        self.validate()?;
323        let next = self.begin_inner(r, purpose, fresh_status)?;
324        next.validate()?;
325        Ok(next)
326    }
327    fn normal_cleanup(&self) -> bool {
328        self.state.phase() == Phase::Running
329            || (self.state.phase() == Phase::Stopped
330                && self
331                    .attempts
332                    .iter()
333                    .rev()
334                    .find(|a| !matches!(a.request.operation, Operation::Status { .. }))
335                    .is_some_and(|a| {
336                        a.purpose == Purpose::ServiceCleanup
337                            && matches!(
338                                a.response.as_ref().map(|r| &r.outcome),
339                                Some(Outcome::Stopped { .. })
340                            )
341                    }))
342    }
343    fn begin_inner(&self, r: &Request, purpose: Purpose, fresh_status: bool) -> Result<Self> {
344        self.check_request(r)?;
345        if self.pending.is_some()
346            || self.attempts.len() >= MAX_ATTEMPTS
347            || self.state.phase() == Phase::VerifiedAbsent
348            || r.binding.generation
349                != self
350                    .revision
351                    .checked_add(1)
352                    .ok_or(Invalid("generation overflow"))?
353            || self
354                .attempts
355                .iter()
356                .any(|a| a.request.binding.invocation == r.binding.invocation)
357        {
358            return Err(Invalid("journal request replay or unfinished attempt"));
359        }
360        let mut next = self.clone();
361        next.state = self.begin_state(r, purpose, fresh_status)?;
362        next.revision = r.binding.generation;
363        next.pending = Some(Pending {
364            request: r.clone(),
365            purpose,
366        });
367        Ok(next)
368    }
369    fn cleanup_authorized(&self, purpose: Purpose) -> bool {
370        purpose == Purpose::OperatorCleanup
371            || (purpose == Purpose::ServiceCleanup && self.normal_cleanup())
372    }
373    fn begin_state(&self, r: &Request, purpose: Purpose, fresh_status: bool) -> Result<State> {
374        let status = self
375            .attempts
376            .last()
377            .filter(|_| fresh_status)
378            .and_then(|a| a.response.as_ref())
379            .filter(|r| matches!(r.operation, Operation::Status { .. }));
380        let observed = match status.map(|r| &r.outcome) {
381            Some(Outcome::Status { object }) => Some(object.state),
382            _ => None,
383        };
384        let state = match &r.operation {
385            Operation::Status { .. } => {
386                if matches!(
387                    self.state.phase(),
388                    Phase::Retained | Phase::ReconcilingRemove
389                ) && matches!(purpose, Purpose::Service | Purpose::ServiceCleanup)
390                {
391                    return Err(Invalid("recovered lease requires explicit reconciliation"));
392                }
393                self.state.clone()
394            }
395            Operation::Start { .. }
396                if purpose == Purpose::Service && observed == Some(ContainerState::Created) =>
397            {
398                self.state.transition(Event::BeginStart)?
399            }
400            Operation::HealthExec { .. }
401                if purpose == Purpose::Service && observed == Some(ContainerState::Running) =>
402            {
403                self.state.transition(Event::BeginHealth)?
404            }
405            Operation::Stop { .. }
406                if self.cleanup_authorized(purpose)
407                    && observed == Some(ContainerState::Running) =>
408            {
409                let ev = if self.state.phase() == Phase::ReconcilingRemove {
410                    Event::OperatorCleanupRunningVerified
411                } else {
412                    Event::CleanupRunningVerified
413                };
414                self.state.transition(ev)?
415            }
416            Operation::Remove { .. }
417                if self.cleanup_authorized(purpose)
418                    && matches!(
419                        observed,
420                        Some(ContainerState::Created | ContainerState::Exited)
421                    ) =>
422            {
423                let stopped = if self.state.phase() == Phase::Stopped {
424                    self.state.clone()
425                } else {
426                    self.state
427                        .transition(if self.state.phase() == Phase::ReconcilingRemove {
428                            Event::OperatorCleanupTerminalVerified
429                        } else {
430                            Event::CleanupTerminalVerified
431                        })?
432                };
433                stopped.transition(Event::BeginRemove)?
434            }
435            _ => {
436                return Err(Invalid(
437                    "journal mutation lacks fresh evidence or authorization",
438                ))
439            }
440        };
441        Ok(state)
442    }
443    fn observe(&self, bytes: Option<&[u8]>) -> Result<Self> {
444        self.validate()?;
445        let next = self.observe_inner(bytes)?;
446        next.validate()?;
447        Ok(next)
448    }
449    fn observe_inner(&self, bytes: Option<&[u8]>) -> Result<Self> {
450        let pending = self
451            .pending
452            .as_ref()
453            .ok_or(Invalid("no journal request pending"))?;
454        let response = bytes
455            .map(|b| wire::parse_response(b, &pending.request))
456            .transpose()?;
457        let mut next = self.clone();
458        let outcome = response.as_ref().map(|r| &r.outcome);
459        self.validate_observed_image(outcome)?;
460        next.state = match (&pending.request.operation, outcome) {
461            (_, None | Some(Outcome::Unknown { .. } | Outcome::RejectedBeforeEffect { .. })) => {
462                self.state.recover()?
463            }
464            (Operation::Create { .. }, Some(Outcome::Created { object })) => self
465                .state
466                .transition(Event::Created(object.object_id.clone()))?,
467            (Operation::Status { .. }, Some(Outcome::Status { .. })) => {
468                if matches!(self.state.phase(), Phase::AwaitingAbsence | Phase::Removing) {
469                    if !matches!(
470                        pending.purpose,
471                        Purpose::OperatorCleanup | Purpose::Reconcile
472                    ) {
473                        return Err(Invalid("present removal requires reconciliation purpose"));
474                    }
475                    self.state.recover()?
476                } else {
477                    self.state.clone()
478                }
479            }
480            (Operation::Status { .. }, Some(Outcome::Absent { .. })) => {
481                let (state, proof) = self.observe_absence(
482                    pending,
483                    response
484                        .clone()
485                        .ok_or(Invalid("missing absence response"))?,
486                )?;
487                next.absence = Some(proof);
488                state
489            }
490            (Operation::Start { .. }, Some(Outcome::Started { .. })) => {
491                self.state.transition(Event::RunningVerified)?
492            }
493            (Operation::Stop { .. }, Some(Outcome::Stopped { .. })) => {
494                self.state.transition(Event::StoppedVerified)?
495            }
496            (Operation::Remove { .. }, Some(Outcome::Removed)) => {
497                self.state.transition(Event::RemoveClaimed)?
498            }
499            (Operation::HealthExec { .. }, Some(Outcome::Health { exit_code: 0, .. })) => {
500                self.state.transition(Event::HealthVerified)?
501            }
502            (Operation::HealthExec { .. }, Some(Outcome::Health { .. })) => {
503                self.state.transition(Event::Failed)?
504            }
505            _ => return Err(Invalid("journal outcome cannot advance state")),
506        };
507        next.attempts.push(Attempt {
508            request: pending.request.clone(),
509            purpose: pending.purpose,
510            response,
511        });
512        next.pending = None;
513        Ok(next)
514    }
515    fn observe_absence(
516        &self,
517        pending: &Pending,
518        response: Response,
519    ) -> Result<(State, AbsenceProof)> {
520        let ev = match (self.state.phase(), pending.purpose) {
521            (
522                Phase::AwaitingAbsence | Phase::ReconcilingRemove,
523                Purpose::Reconcile | Purpose::OperatorCleanup,
524            ) => Event::AbsenceVerified,
525            (Phase::Retained, Purpose::OperatorCleanup) => Event::OperatorAbsenceVerified,
526            _ => return Err(Invalid("absence lacks reconciliation authorization")),
527        };
528        let remove = self
529            .attempts
530            .iter()
531            .rev()
532            .find(|a| matches!(a.request.operation, Operation::Remove { .. }))
533            .map(|a| a.request.binding.operation_key.clone());
534        let proof = AbsenceProof {
535            reason: if remove.is_some() {
536                AbsenceReason::PostRemoveAbsence
537            } else {
538                AbsenceReason::OperatorObservedAbsence
539            },
540            remove_request_sha256: remove,
541            status_request_sha256: pending.request.binding.operation_key.clone(),
542            response,
543        };
544        Ok((self.state.transition(ev)?, proof))
545    }
546    fn validate_observed_image(&self, outcome: Option<&Outcome>) -> Result<()> {
547        // The exact pinned image remains immutable across every returned object.
548        if let Some(
549            Outcome::Created { object }
550            | Outcome::Status { object }
551            | Outcome::Started { object }
552            | Outcome::Stopped { object }
553            | Outcome::Health { object, .. },
554        ) = outcome
555        {
556            if let Operation::Create { image_id, .. } = &self.create.operation {
557                if &object.image_id != image_id {
558                    return Err(Invalid("journal image changed"));
559                }
560            }
561        }
562        Ok(())
563    }
564    fn recover(&self) -> Result<Self> {
565        let mut next = if self.pending.is_some() {
566            self.observe(None)?
567        } else {
568            self.clone()
569        };
570        let recovered = next.state.recover()?;
571        if recovered != next.state {
572            next.recoveries.push(next.revision);
573            next.state = recovered;
574        }
575        next.validate()?;
576        Ok(next)
577    }
578}