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}