Skip to main content

newt_interaction/
ids.rs

1//! Identifiers. Three content-derived ids, and the locators that travel
2//! beside them.
3//!
4//! The content-derived ids ([`DefinitionId`], [`InstanceId`], [`ResponseId`])
5//! are newtypes over `ContentId` with **no string constructor** — the only
6//! ways in are minting from a record and [`parse`](DefinitionId::parse),
7//! which refuses any spelling that is not the id's own canonical rendering.
8//! Two strings must never name one record.
9
10use content_addressable::ContentId;
11use serde::{Deserialize, Serialize};
12
13use crate::error::ProtocolError;
14
15/// Generate the shared body of a content-derived id newtype.
16macro_rules! content_id_newtype {
17    ($(#[$meta:meta])* $name:ident, $kind:literal) => {
18        $(#[$meta])*
19        #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
21        #[serde(transparent)]
22        // `ContentId` is a foreign type with no `JsonSchema` impl. In JSON
23        // it renders as its canonical string, which is what a schema
24        // consumer sees; in canonical DAG-CBOR it is a tag-42 LINK, which
25        // JSON Schema has no way to express and the vectors' `links` field
26        // documents instead.
27        pub struct $name(
28            #[cfg_attr(feature = "schema", schemars(with = "String"))] ContentId,
29        );
30
31        impl $name {
32            /// Wrap an id already minted from the record it names.
33            ///
34            /// Crate-private on purpose: a public constructor would let any
35            /// `ContentId` be DECLARED a definition id, which is the
36            /// assigned-identity failure the whole crate exists to avoid.
37            /// Records mint their own typed ids; the sanctioned ways in from
38            /// outside are [`parse`](Self::parse), which refuses a
39            /// non-canonical spelling, and serde at a wire boundary — after
40            /// which A3 revalidates against the record anyway.
41            #[must_use]
42            pub(crate) fn from_content_id(id: ContentId) -> Self {
43                Self(id)
44            }
45
46            /// The underlying content id.
47            #[must_use]
48            pub fn content_id(&self) -> &ContentId {
49                &self.0
50            }
51
52            /// Parse an id from its canonical rendering.
53            ///
54            /// # Errors
55            ///
56            /// [`ProtocolError::NonCanonicalId`] when `text` parses but is not
57            /// byte-identical to the id's own rendering — an alternate
58            /// spelling would let two strings name one record.
59            pub fn parse(text: &str) -> Result<Self, ProtocolError> {
60                let id: ContentId = text.parse().map_err(|_| ProtocolError::InvalidId {
61                    kind: $kind,
62                    reason: format!("`{text}` is not a content id"),
63                })?;
64                if id.to_string() != text {
65                    return Err(ProtocolError::NonCanonicalId {
66                        presented: text.to_string(),
67                    });
68                }
69                Ok(Self(id))
70            }
71        }
72
73        impl std::fmt::Display for $name {
74            fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
75                write!(f, "{}", self.0)
76            }
77        }
78    };
79}
80
81content_id_newtype!(
82    /// The identity of an [`InteractionDefinition`](crate::InteractionDefinition)
83    /// — and therefore its **exact form digest**. A response binds this, so a
84    /// definition that changed by one byte cannot be answered with an offer
85    /// minted against the old one.
86    DefinitionId,
87    "definition id"
88);
89content_id_newtype!(
90    /// The identity of an [`InteractionInstance`](crate::InteractionInstance).
91    /// Distinct from the instance's [`Nonce`], which routes.
92    InstanceId,
93    "instance id"
94);
95content_id_newtype!(
96    /// The identity of a [`Response`](crate::Response).
97    ResponseId,
98    "response id"
99);
100
101/// Shared validation for author-assigned names: non-empty, ASCII
102/// alphanumeric plus `-` and `_`.
103fn validated_name(kind: &'static str, name: String) -> Result<String, ProtocolError> {
104    if name.is_empty() {
105        return Err(ProtocolError::InvalidId {
106            kind,
107            reason: "must not be empty".to_string(),
108        });
109    }
110    if !name
111        .chars()
112        .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
113    {
114        return Err(ProtocolError::InvalidId {
115            kind,
116            reason: format!("`{name}` has characters outside [A-Za-z0-9_-]"),
117        });
118    }
119    Ok(name)
120}
121
122/// A stable, author-assigned name for one control — one FIELD of an
123/// interaction — inside a definition.
124///
125/// Deliberately not content-derived: a control id must survive being
126/// written by a human in a `+++` envelope, and the definition it lives in
127/// commits to it, so the definition's `ContentId` is what protects its
128/// integrity.
129#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
130// Deserialization goes THROUGH the constructor. A derived impl on a
131// transparent newtype builds the private field directly, so an invalid
132// value arrives intact in canonical bytes that re-encode identically —
133// invisible to the byte-equality gate, and a guarantee the type's own
134// docs would otherwise be claiming falsely.
135#[serde(into = "String", try_from = "String")]
136pub struct ControlId(String);
137
138#[cfg(feature = "schema")]
139crate::string_scalar_schema!(ControlId, Some("^[A-Za-z0-9_-]+$"));
140
141impl From<ControlId> for String {
142    fn from(value: ControlId) -> Self {
143        value.0
144    }
145}
146
147impl TryFrom<String> for ControlId {
148    type Error = ProtocolError;
149
150    fn try_from(value: String) -> Result<Self, Self::Error> {
151        Self::new(value)
152    }
153}
154
155impl ControlId {
156    /// Build a control id: non-empty, ASCII alphanumeric plus `-` and `_`.
157    ///
158    /// # Errors
159    ///
160    /// [`ProtocolError::InvalidId`] when empty or outside the charset.
161    pub fn new(name: impl Into<String>) -> Result<Self, ProtocolError> {
162        Ok(Self(validated_name("control id", name.into())?))
163    }
164
165    /// The name as written.
166    #[must_use]
167    pub fn as_str(&self) -> &str {
168        &self.0
169    }
170}
171
172/// A stable, author-assigned name for one OPTION of a choice control.
173///
174/// A separate id space from [`ControlId`] on purpose. A choice is one
175/// field with many mutually-exclusive options — the shape B0's permission
176/// prompt already has: one question offering `[a]llow once`,
177/// `[s]ession allow`, `[d]eny (default)` and the rest. Giving options
178/// their own type means a submission naming a control and an option cannot
179/// name two things of the same kind with no rule saying which is
180/// authoritative; the ambiguity is gone by construction rather than by
181/// prose.
182#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
183// Deserialization goes THROUGH the constructor. A derived impl on a
184// transparent newtype builds the private field directly, so an invalid
185// value arrives intact in canonical bytes that re-encode identically —
186// invisible to the byte-equality gate, and a guarantee the type's own
187// docs would otherwise be claiming falsely.
188#[serde(into = "String", try_from = "String")]
189pub struct OptionId(String);
190
191#[cfg(feature = "schema")]
192crate::string_scalar_schema!(OptionId, Some("^[A-Za-z0-9_-]+$"));
193
194impl From<OptionId> for String {
195    fn from(value: OptionId) -> Self {
196        value.0
197    }
198}
199
200impl TryFrom<String> for OptionId {
201    type Error = ProtocolError;
202
203    fn try_from(value: String) -> Result<Self, Self::Error> {
204        Self::new(value)
205    }
206}
207
208impl OptionId {
209    /// Build an option id: same shape rule as a control id.
210    ///
211    /// # Errors
212    ///
213    /// [`ProtocolError::InvalidId`] when empty or outside the charset.
214    pub fn new(name: impl Into<String>) -> Result<Self, ProtocolError> {
215        Ok(Self(validated_name("option id", name.into())?))
216    }
217
218    /// The name as written.
219    #[must_use]
220    pub fn as_str(&self) -> &str {
221        &self.0
222    }
223}
224
225/// A host-minted, fresh, unguessable handle for one live instance.
226///
227/// **The one thing here that is not content-derived, deliberately.** It makes
228/// an instance unenumerable; it does not make its holder authorized. The
229/// controller revalidates every response against the definition regardless of
230/// who presented which nonce.
231#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
232// Deserialization goes THROUGH the constructor. A derived impl on a
233// transparent newtype builds the private field directly, so an invalid
234// value arrives intact in canonical bytes that re-encode identically —
235// invisible to the byte-equality gate, and a guarantee the type's own
236// docs would otherwise be claiming falsely.
237#[serde(into = "String", try_from = "String")]
238pub struct Nonce(String);
239
240#[cfg(feature = "schema")]
241crate::string_scalar_schema!(Nonce, None::<&str>);
242
243impl From<Nonce> for String {
244    fn from(value: Nonce) -> Self {
245        value.0
246    }
247}
248
249impl TryFrom<String> for Nonce {
250    type Error = ProtocolError;
251
252    fn try_from(value: String) -> Result<Self, Self::Error> {
253        Self::new(value)
254    }
255}
256
257impl Nonce {
258    /// Adopt a nonce minted by the host.
259    ///
260    /// # Errors
261    ///
262    /// [`ProtocolError::InvalidId`] when empty.
263    pub fn new(value: impl Into<String>) -> Result<Self, ProtocolError> {
264        let value = value.into();
265        if value.is_empty() {
266            return Err(ProtocolError::InvalidId {
267                kind: "nonce",
268                reason: "must not be empty".to_string(),
269            });
270        }
271        Ok(Self(value))
272    }
273
274    /// The handle as minted.
275    #[must_use]
276    pub fn as_str(&self) -> &str {
277        &self.0
278    }
279}
280
281/// A definition's revision counter.
282///
283/// `Revision(0)` is a VALID revision, so a "consumed through" marker must be
284/// `Option<Revision>` and never a `0` sentinel. That lesson is already paid
285/// for in `newt_core::agentic::steering::Rev` (`steering.rs:110-115`); this
286/// crate cannot depend on `newt-core` (dependency direction is binding), so
287/// it carries the lesson rather than the type. #1828 records moving `Rev`
288/// down here as a named follow-up.
289#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
290#[derive(
291    Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, Default,
292)]
293#[serde(transparent)]
294pub struct Revision(u64);
295
296impl Revision {
297    /// The first revision of a definition.
298    pub const FIRST: Self = Self(0);
299
300    /// Adopt a revision number.
301    #[must_use]
302    pub fn new(value: u64) -> Self {
303        Self(value)
304    }
305
306    /// The next revision.
307    #[must_use]
308    pub fn next(self) -> Self {
309        Self(self.0.saturating_add(1))
310    }
311
312    /// The raw counter.
313    #[must_use]
314    pub fn get(self) -> u64 {
315        self.0
316    }
317}
318
319/// A caller-supplied key that makes a response replay-safe: the same key on
320/// the same instance is the same submission, not a second one.
321#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
322// Deserialization goes THROUGH the constructor. A derived impl on a
323// transparent newtype builds the private field directly, so an invalid
324// value arrives intact in canonical bytes that re-encode identically —
325// invisible to the byte-equality gate, and a guarantee the type's own
326// docs would otherwise be claiming falsely.
327#[serde(into = "String", try_from = "String")]
328pub struct IdempotencyKey(String);
329
330#[cfg(feature = "schema")]
331crate::string_scalar_schema!(IdempotencyKey, None::<&str>);
332
333impl From<IdempotencyKey> for String {
334    fn from(value: IdempotencyKey) -> Self {
335        value.0
336    }
337}
338
339impl TryFrom<String> for IdempotencyKey {
340    type Error = ProtocolError;
341
342    fn try_from(value: String) -> Result<Self, Self::Error> {
343        Self::new(value)
344    }
345}
346
347impl IdempotencyKey {
348    /// Adopt an idempotency key.
349    ///
350    /// # Errors
351    ///
352    /// [`ProtocolError::InvalidId`] when empty.
353    pub fn new(value: impl Into<String>) -> Result<Self, ProtocolError> {
354        let value = value.into();
355        if value.is_empty() {
356            return Err(ProtocolError::InvalidId {
357                kind: "idempotency key",
358                reason: "must not be empty".to_string(),
359            });
360        }
361        Ok(Self(value))
362    }
363
364    /// The key as supplied.
365    #[must_use]
366    pub fn as_str(&self) -> &str {
367        &self.0
368    }
369}