Skip to main content

newt_interaction/
response.rs

1//! The typed response: what a responder submitted, bound to exactly what
2//! they were offered.
3
4use content_addressable::{canonical, ContentAddressable, ContentError};
5use serde::{Deserialize, Serialize};
6
7use crate::error::ProtocolError;
8use crate::ids::{
9    ControlId, DefinitionId, IdempotencyKey, InstanceId, OptionId, ResponseId, Revision,
10};
11use crate::instance::Audience;
12
13/// The versioned type tag every response carries.
14pub const RESPONSE_SCHEMA_V1: &str = "newt.interaction.response/v1";
15
16/// A reference to a secret the host holds — never the secret.
17///
18/// A response is durable, content-addressed, and tamper-evident, which is
19/// exactly what makes a secret inside one a permanent disclosure
20/// liability. The record names the sealed value; resolving the handle is
21/// the host's business and requires its own authority (ADR D1: never
22/// persist secret values in markup or logs).
23#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
24// See the note on `ControlId`: deserialization goes through the
25// constructor so the wire cannot carry an empty reference.
26#[serde(into = "String", try_from = "String")]
27pub struct SecretRef(String);
28
29#[cfg(feature = "schema")]
30crate::string_scalar_schema!(SecretRef, None::<&str>);
31
32impl From<SecretRef> for String {
33    fn from(value: SecretRef) -> Self {
34        value.0
35    }
36}
37
38impl TryFrom<String> for SecretRef {
39    type Error = ProtocolError;
40
41    fn try_from(value: String) -> Result<Self, Self::Error> {
42        Self::new(value)
43    }
44}
45
46impl SecretRef {
47    /// Adopt a handle the host can resolve.
48    ///
49    /// # Errors
50    ///
51    /// [`ProtocolError::InvalidId`] when empty — a reference that
52    /// references nothing is not a reference.
53    pub fn new(handle: impl Into<String>) -> Result<Self, ProtocolError> {
54        let handle = handle.into();
55        if handle.is_empty() {
56            return Err(ProtocolError::InvalidId {
57                kind: "secret reference",
58                reason: "must not be empty".to_string(),
59            });
60        }
61        Ok(Self(handle))
62    }
63
64    /// The handle as adopted.
65    #[must_use]
66    pub fn as_str(&self) -> &str {
67        &self.0
68    }
69}
70
71/// What was submitted for one control, typed.
72///
73/// Typed at the TYPE rather than at a downstream parser: a `String` here
74/// would make every consumer decide whether `"TRUE"`, `"1"`, and `"yes"`
75/// are the same toggle, which is the per-surface drift the epic exists to
76/// end. Canonicalization lives in the shape, so A3 revalidates rather than
77/// re-parses.
78/// Deliberately NOT `#[non_exhaustive]`, unlike the open vocabularies in
79/// this crate (`InteractionKind`, `SemanticRole`, `Audience`, …). This
80/// variant set is a security boundary: "no variant can carry a secret" is
81/// provable only by an exhaustive match, and `#[non_exhaustive]` would
82/// force every such match to end in a wildcard — after which adding a
83/// plaintext-carrying variant would compile silently everywhere. Adding a
84/// variant here SHOULD break every consumer that reasons about the set.
85/// Every variant is a STRUCT variant: serde's internally-tagged form
86/// cannot represent a newtype variant wrapping a primitive, and the named
87/// field is the clearer thing to freeze into A2.1's vectors anyway
88/// (`{"kind":"toggle","on":true}`).
89#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
90#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
91#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)]
92pub enum ControlValue {
93    /// One of the control's options was chosen.
94    Choice {
95        /// Which OPTION of the control named by the enclosing
96        /// [`Submission`]. A distinct id space from `ControlId`, so the
97        /// pair cannot name two things of the same kind with no rule
98        /// saying which wins. Its charset is enforced on construction AND
99        /// on deserialization, so the guarantee holds on the wire too.
100        option: OptionId,
101    },
102    /// Free text, as typed.
103    Text {
104        /// The submitted text.
105        text: String,
106    },
107    /// A boolean. Travels as a bool, never as text.
108    Toggle {
109        /// Whether the control is on.
110        on: bool,
111    },
112    /// A secret, BY REFERENCE. There is deliberately no variant that can
113    /// hold plaintext.
114    Secret {
115        /// The handle the host can resolve.
116        reference: SecretRef,
117    },
118}
119
120/// How a responder was authenticated, IDENTIFIED — never the credential
121/// itself.
122///
123/// A2 gives responder provenance a place to live and binds it into the
124/// response's identity; A3 authenticates it and revalidates it. The
125/// separation matters: an [`Audience`] says which KIND of surface answered,
126/// which is not who did.
127///
128/// **This record carries no credentials and no secrets.** It names the
129/// assertion by reference — a kind, a subject, and an opaque
130/// [`assertion`](ResponderProvenance::assertion) handle the host can
131/// resolve — so a durable, content-addressed response never becomes a
132/// disclosure liability. A digest in the record beats the bytes: it is
133/// tamper-evident without being a secret.
134#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
135#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
136#[serde(deny_unknown_fields)]
137pub struct ResponderProvenance {
138    /// What kind of assertion authenticated this responder.
139    pub kind: AssertionKind,
140    /// The principal the assertion speaks for, as the host names it. A
141    /// stable reference, never a credential.
142    pub subject: String,
143    /// Which audience the assertion was presented from.
144    pub audience: Audience,
145    /// An opaque handle the host can resolve to the assertion itself — a
146    /// content id, a row id, or a session reference. `None` when the
147    /// responder was unauthenticated, which is a fact worth recording
148    /// rather than hiding: A3 decides whether that is admissible.
149    pub assertion: Option<String>,
150}
151
152/// What established the responder's identity.
153#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
154#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
155#[serde(rename_all = "kebab-case")]
156#[non_exhaustive]
157pub enum AssertionKind {
158    /// The operator at the terminal that owns the session; authority comes
159    /// from holding the terminal, not from a presented token.
160    TerminalOperator,
161    /// A signed assertion from an enrolled credential.
162    SignedAssertion,
163    /// No assertion was presented. Recorded, not hidden.
164    Unauthenticated,
165}
166
167/// One control's answer: which control, and the typed value.
168#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
169#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
170#[serde(deny_unknown_fields)]
171pub struct Submission {
172    /// Which control this answers.
173    pub control: ControlId,
174    /// What was submitted.
175    pub value: ControlValue,
176}
177
178/// A submission bound to the exact offer it answers.
179///
180/// Binds type, definition, instance, digest, revision, control values,
181/// idempotency key, and responder provenance — the ADR's own list. Nothing
182/// here authorizes on its own: the controller revalidates every field
183/// against the definition before it resolves anything.
184#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
185#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
186#[serde(deny_unknown_fields)]
187pub struct Response {
188    /// Versioned type tag; see [`RESPONSE_SCHEMA_V1`].
189    pub schema: crate::tag::ResponseTag,
190    /// The definition answered — and, being a content id, its exact form
191    /// digest. A definition that changed by one byte cannot be answered by
192    /// a response minted against the old one.
193    pub definition: DefinitionId,
194    /// The instance answered.
195    pub instance: InstanceId,
196    /// The revision the responder saw.
197    pub revision: Revision,
198    /// The submitted values, each against the control it answers.
199    pub values: Vec<Submission>,
200    /// Makes a retry the same submission rather than a second one.
201    pub idempotency_key: IdempotencyKey,
202    /// How the responder was authenticated, by reference — including which
203    /// audience they answered from. Bound into the response's identity, so
204    /// a submission cannot be re-attributed after the fact.
205    ///
206    /// There is deliberately no second `responder: Audience` field beside
207    /// this one. Two bound fields carrying the same fact can disagree, and
208    /// nothing in a plain record keeps them equal; the identity table would
209    /// then be pinning a contradiction. The audience a responder was
210    /// authenticated FROM is part of the assertion, so it lives with it.
211    pub responder_provenance: ResponderProvenance,
212}
213
214impl Response {
215    /// This response's identity.
216    ///
217    /// # Errors
218    ///
219    /// Propagates a canonical-encoding failure.
220    pub fn response_id(&self) -> Result<ResponseId, ProtocolError> {
221        Ok(ResponseId::from_content_id(self.content_id()?))
222    }
223
224    /// The schema tag, as a string.
225    ///
226    /// Reading it needs no check: the tag is a TYPE that deserializes from
227    /// exactly one value, so a record of this type cannot carry any other
228    /// one. What used to be a runtime `ensure_known_schema` is now a thing
229    /// the wire cannot express — and the published schema says so with a
230    /// `const`, so a foreign implementor validating against the wrong
231    /// record's schema fails instead of passing.
232    #[must_use]
233    pub fn schema_tag(&self) -> &'static str {
234        self.schema.as_str()
235    }
236}
237
238impl ContentAddressable for Response {
239    fn canonical_form(&self) -> Result<Vec<u8>, ContentError> {
240        // The whole submission is the identity — the ADR's list in full:
241        // type, definition, instance, digest, revision, control values,
242        // idempotency key, and responder provenance.
243        canonical::to_canonical_dagcbor(self)
244    }
245}