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}