Skip to main content

newt_interaction/
instance.rs

1//! The host-minted offer: the binding of a definition to an audience,
2//! kept OUT of the definition so definition bytes stay immutable (ADR law
3//! 12).
4//!
5//! **`InstanceId` is the identity of the OFFER, never of its state.** The
6//! record binds what the offer IS — which definition, at which revision, to
7//! whom, under which fence, until when, from where — and nothing that
8//! changes as the interaction proceeds. Lifecycle, progress, responses, and
9//! resolution are out of band (ADR law 8): A3's transition records carry
10//! them and reference this stable id. An id that moved when the state moved
11//! would be a snapshot id, and every response binding the earlier snapshot
12//! would dangle.
13
14use content_addressable::{canonical, ContentAddressable, ContentError};
15use serde::{Deserialize, Serialize};
16
17use crate::error::ProtocolError;
18use crate::ids::{DefinitionId, InstanceId, Nonce, Revision};
19
20/// The versioned type tag every instance carries.
21pub const INSTANCE_SCHEMA_V1: &str = "newt.interaction.instance/v1";
22
23/// Where an instance is in its life.
24///
25/// A2 defines the STATES; A3 drives the transitions. Deliberately NOT a
26/// field of [`InteractionInstance`]: state is out of band (ADR laws 8 and
27/// 12), so A3's transition records carry it and reference the offer by its
28/// stable [`InstanceId`](crate::InstanceId). Putting it inside the
29/// content-addressed record would make that id name a snapshot — a
30/// Published X would become an Answered Y, and a response that bound X
31/// would refer to nothing the store still holds.
32#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
34#[serde(rename_all = "kebab-case")]
35#[non_exhaustive]
36pub enum LifecycleState {
37    /// Minted, not yet offered.
38    Draft,
39    /// Offered to eligible responders.
40    Published,
41    /// Resolved by a valid response.
42    Answered,
43    /// Backed out without a decision.
44    Cancelled,
45    /// Its TTL elapsed. Expiry never authorizes.
46    Expired,
47    /// No attached surface can present a required control, so it fails
48    /// closed rather than guessing (ADR law 5).
49    Unsupported,
50}
51
52/// Who may answer, as distinct from what a surface can draw.
53#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
54#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
55#[serde(rename_all = "kebab-case")]
56#[non_exhaustive]
57pub enum Audience {
58    /// The operator at the terminal that owns this session.
59    Terminal,
60    /// An authenticated, in-scope web attachment.
61    Web,
62}
63
64/// The frozen eligibility rule for one instance.
65///
66/// Frozen at publication: an offer cannot widen who may answer it after the
67/// fact, which is the "attenuate, never amplify" law applied to responders.
68#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
69#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
70#[serde(deny_unknown_fields)]
71pub struct ResponderPolicy {
72    /// Audiences this offer is open to.
73    pub audiences: Vec<Audience>,
74    /// Whether the responder must present an authenticated assertion.
75    pub requires_assertion: bool,
76}
77
78/// The workspace fence an instance is confined to.
79#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
80#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
81#[serde(deny_unknown_fields)]
82pub struct Scope {
83    /// Opaque workspace key — the same fence every permission query already
84    /// applies in `newt_core`'s store.
85    pub workspace_key: String,
86    /// The conversation this instance belongs to.
87    pub conversation_id: String,
88}
89
90/// Where an instance came from — the record's own sources.
91///
92/// Mandatory, not optional. The repo's one outstanding first-principle
93/// violation is exactly this hole in `ConversationTurn`
94/// (`first_principle.rs:802`); a new record type that cannot name its origin
95/// repeats the defect the ledger counts.
96#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
97#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
98#[serde(deny_unknown_fields)]
99pub struct Provenance {
100    /// What minted this instance (a tool name, a subsystem, a persona).
101    pub origin: String,
102    /// The tick at which it was minted, as the host counts ticks.
103    pub minted_tick: i64,
104}
105
106/// A live offer of one definition to one audience.
107///
108/// Binds the routing nonce to the definition's content id, the revision, the
109/// TTL, the scope, the frozen responder policy, and the provenance — and the
110/// whole binding is itself content-addressed, so an offer cannot be altered
111/// without changing its [`InstanceId`].
112#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
113#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
114#[serde(deny_unknown_fields)]
115pub struct InteractionInstance {
116    /// Versioned type tag; see [`INSTANCE_SCHEMA_V1`].
117    pub schema: crate::tag::InstanceTag,
118    /// Fresh, unguessable routing handle. Not the identity.
119    pub nonce: Nonce,
120    /// The definition being offered — its exact form digest.
121    pub definition: DefinitionId,
122    /// The revision offered.
123    pub revision: Revision,
124    /// Ticks after `provenance.minted_tick` at which this offer expires.
125    pub ttl_ticks: i64,
126    /// The workspace fence.
127    pub scope: Scope,
128    /// Frozen eligibility.
129    pub responder_policy: ResponderPolicy,
130    /// Where this offer came from.
131    pub provenance: Provenance,
132}
133
134impl InteractionInstance {
135    /// This instance's identity.
136    ///
137    /// # Errors
138    ///
139    /// Propagates a canonical-encoding failure.
140    pub fn instance_id(&self) -> Result<InstanceId, ProtocolError> {
141        Ok(InstanceId::from_content_id(self.content_id()?))
142    }
143
144    /// The schema tag, as a string.
145    ///
146    /// Reading it needs no check: the tag is a TYPE that deserializes from
147    /// exactly one value, so a record of this type cannot carry any other
148    /// one. What used to be a runtime `ensure_known_schema` is now a thing
149    /// the wire cannot express — and the published schema says so with a
150    /// `const`, so a foreign implementor validating against the wrong
151    /// record's schema fails instead of passing.
152    #[must_use]
153    pub fn schema_tag(&self) -> &'static str {
154        self.schema.as_str()
155    }
156}
157
158impl ContentAddressable for InteractionInstance {
159    fn canonical_form(&self) -> Result<Vec<u8>, ContentError> {
160        // The whole binding is the identity: nonce, definition, revision,
161        // TTL, scope, responder policy, provenance, and lifecycle. An offer
162        // whose fence or expiry could change without changing its id is an
163        // offer that can be silently re-aimed.
164        canonical::to_canonical_dagcbor(self)
165    }
166}