Skip to main content

newt_interaction/
lifecycle.rs

1//! **The host-minted lifecycle** (A3, #1837).
2//!
3//! State is out of band (ADR laws 8 and 12): [`InteractionInstance`] names
4//! the OFFER and never its state, so the state machine lives here and
5//! references the offer by its stable [`InstanceId`].
6//!
7//! Two properties are load bearing, and both are about what this module
8//! REFUSES to do.
9//!
10//! **Untrusted markup cannot publish** (law 11). Publication requires a
11//! [`HostMint`] — a token with a private field, no `Deserialize`, no
12//! `Default`, and no `Clone`. A document is DATA: decoding produces
13//! records, and no sequence of decodes produces a `HostMint`. The runtime
14//! half is checked too: publication verifies that the offer's digest and
15//! revision match the definition the HOST holds, so an instance record
16//! naming a definition the host never presented is refused rather than
17//! taken on its own word.
18//!
19//! **Expiry synthesizes no response.** [`SemanticRole`] and
20//! [`ChoiceOption::role`] are AUTHOR-assigned
21//! (`definition.rs`, `ids.rs`), and the author may be untrusted markup. A
22//! controller that computed its expiry default by scanning the definition
23//! for `role == Deny` would make an attacker-chosen field decide what
24//! "fail closed" means — and a mislabelled option (`label: "deny"`,
25//! `role: Allow`) would become a path to authorization that the old
26//! fixed-enum code structurally could not reach. So expiry is a pure
27//! no-decision transition: it moves the state to
28//! [`LifecycleState::Expired`] and produces no [`Response`] at all. There
29//! is deliberately no API here that returns one.
30//!
31//! [`Response`]: crate::Response
32//! [`SemanticRole`]: crate::SemanticRole
33//! [`ChoiceOption::role`]: crate::ChoiceOption::role
34
35use thiserror::Error;
36
37use crate::definition::InteractionDefinition;
38use crate::error::ProtocolError;
39use crate::ids::InstanceId;
40use crate::instance::{InteractionInstance, LifecycleState};
41
42/// The host's authority to publish an offer.
43///
44/// Deliberately not `Deserialize`, `Default`, `Clone`, or `Copy`: the
45/// point is that no decoded document, and no value threaded out of one,
46/// can produce it. Only host code that already holds the authority to
47/// offer an interaction constructs one.
48#[derive(Debug)]
49pub struct HostMint {
50    /// Private, so the struct-literal form is unavailable outside this
51    /// module and [`HostMint::assert_host_authority`] is the only door.
52    _private: (),
53}
54
55impl HostMint {
56    /// Assert that the caller is the host.
57    ///
58    /// Name it what it is: a claim by the calling code, checked by the
59    /// dependency direction rather than at runtime. `newt-interaction`
60    /// has no ambient authority to verify it against, and inventing a
61    /// runtime check here would be theatre.
62    #[must_use]
63    pub fn assert_host_authority() -> Self {
64        Self { _private: () }
65    }
66}
67
68/// Why a lifecycle transition was refused.
69#[derive(Debug, Error)]
70pub enum LifecycleError {
71    /// The offer names a different definition than the host presented.
72    #[error("the offer binds definition `{offered}`, but the host presented `{presented}`")]
73    DefinitionMismatch {
74        /// The digest the instance record binds.
75        offered: String,
76        /// The digest of the definition the host actually holds.
77        presented: String,
78    },
79    /// The offer names a different revision than the definition carries.
80    #[error("the offer binds revision {offered}, but the definition is at {presented}")]
81    RevisionMismatch {
82        /// The revision the instance record binds.
83        offered: u64,
84        /// The revision the definition carries.
85        presented: u64,
86    },
87    /// A terminal state accepts no further transition.
88    #[error("{state:?} is terminal; no further transition is possible")]
89    AlreadyTerminal {
90        /// The terminal state the lifecycle is already in.
91        state: LifecycleState,
92    },
93    /// The transition is not one this machine allows.
94    #[error("cannot move from {from:?} to {to:?}")]
95    IllegalTransition {
96        /// Where the lifecycle is.
97        from: LifecycleState,
98        /// Where the caller tried to move it.
99        to: LifecycleState,
100    },
101    /// Content addressing failed while checking the binding.
102    #[error(transparent)]
103    Protocol(#[from] ProtocolError),
104}
105
106/// Where one offer is in its life.
107///
108/// Cheap and `Clone`, because it is a fact ABOUT an offer rather than the
109/// offer itself. Constructing one in [`LifecycleState::Published`] is
110/// possible only through [`publish`], which is what makes "host-minted"
111/// mean something.
112#[derive(Debug, Clone, PartialEq, Eq)]
113pub struct Lifecycle {
114    instance: InstanceId,
115    state: LifecycleState,
116}
117
118impl Lifecycle {
119    /// Which offer this is the state of.
120    #[must_use]
121    pub fn instance(&self) -> &InstanceId {
122        &self.instance
123    }
124
125    /// The current state.
126    #[must_use]
127    pub fn state(&self) -> LifecycleState {
128        self.state
129    }
130
131    /// Whether this state accepts any further transition.
132    #[must_use]
133    pub fn is_terminal(&self) -> bool {
134        terminal(self.state)
135    }
136
137    /// Move to `to`, refusing anything the machine does not allow.
138    ///
139    /// # Errors
140    ///
141    /// [`LifecycleError::AlreadyTerminal`] from any terminal state, and
142    /// [`LifecycleError::IllegalTransition`] for a move this machine does
143    /// not define.
144    pub fn transition(&self, to: LifecycleState) -> Result<Self, LifecycleError> {
145        if terminal(self.state) {
146            return Err(LifecycleError::AlreadyTerminal { state: self.state });
147        }
148        // From a non-terminal state, only a move to a TERMINAL state is
149        // defined. Draft -> Published is `publish`'s job and needs the
150        // host mint, so it is not reachable through here.
151        if !terminal(to) {
152            return Err(LifecycleError::IllegalTransition {
153                from: self.state,
154                to,
155            });
156        }
157        Ok(Self {
158            instance: self.instance,
159            state: to,
160        })
161    }
162
163    /// Expire this offer.
164    ///
165    /// **Synthesizes no [`Response`](crate::Response) and authorizes
166    /// nothing.** The return type carries a state and an id, and there is
167    /// no field or method through which a decision could arrive — which
168    /// is the point: an expiry default read out of an author-supplied
169    /// `role` would let a document decide what failing closed means.
170    ///
171    /// # Errors
172    ///
173    /// [`LifecycleError::AlreadyTerminal`] if it has already resolved.
174    pub fn expire(&self) -> Result<Self, LifecycleError> {
175        self.transition(LifecycleState::Expired)
176    }
177
178    /// Whether this offer's TTL has elapsed at `now_tick`.
179    ///
180    /// A pure comparison against the offer's own minting tick and TTL. It
181    /// reads no clock: `newt-interaction` has no ambient authority, and
182    /// the caller's clock is the one that matters.
183    #[must_use]
184    pub fn has_elapsed(instance: &InteractionInstance, now_tick: i64) -> bool {
185        let deadline = instance
186            .provenance
187            .minted_tick
188            .saturating_add(instance.ttl_ticks);
189        now_tick >= deadline
190    }
191}
192
193/// Is this state terminal?
194///
195/// Exhaustive on purpose — no `_` arm. [`LifecycleState`] is
196/// `#[non_exhaustive]` to its consumers, but this crate DEFINES it, so a
197/// new variant must be classified here before it compiles.
198fn terminal(state: LifecycleState) -> bool {
199    match state {
200        LifecycleState::Draft | LifecycleState::Published => false,
201        LifecycleState::Answered
202        | LifecycleState::Cancelled
203        | LifecycleState::Expired
204        | LifecycleState::Unsupported => true,
205    }
206}
207
208/// Publish a host-minted offer of `definition`.
209///
210/// Requires a [`HostMint`], and verifies the binding the offer claims:
211/// the definition digest and the revision must match the definition the
212/// host is holding. An instance record that names a definition the host
213/// never presented is refused rather than believed.
214///
215/// # Errors
216///
217/// [`LifecycleError::DefinitionMismatch`] or
218/// [`LifecycleError::RevisionMismatch`] when the offer does not bind the
219/// presented definition; [`LifecycleError::Protocol`] if addressing fails.
220pub fn publish(
221    _mint: &HostMint,
222    instance: &InteractionInstance,
223    definition: &InteractionDefinition,
224) -> Result<Lifecycle, LifecycleError> {
225    let presented = definition.definition_id()?;
226    if instance.definition != presented {
227        return Err(LifecycleError::DefinitionMismatch {
228            offered: instance.definition.to_string(),
229            presented: presented.to_string(),
230        });
231    }
232    if instance.revision != definition.revision {
233        return Err(LifecycleError::RevisionMismatch {
234            offered: instance.revision.get(),
235            presented: definition.revision.get(),
236        });
237    }
238    Ok(Lifecycle {
239        instance: instance.instance_id()?,
240        state: LifecycleState::Published,
241    })
242}