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}