Skip to main content

newt_interaction/
definition.rs

1//! The immutable, semantic definition of one interaction.
2
3use content_addressable::{canonical, ContentAddressable, ContentError};
4use serde::{Deserialize, Serialize};
5
6use crate::error::ProtocolError;
7use crate::ids::{ControlId, DefinitionId, OptionId, Revision};
8
9/// The versioned type tag every definition carries as its first field.
10///
11/// Bound into identity, so bumping it re-addresses every definition — the
12/// property `newt_core::agentic::content_spill`'s `SPILL_SCHEMA_V1` already
13/// relies on (`content_spill.rs:45-47`).
14pub const DEFINITION_SCHEMA_V1: &str = "newt.interaction.definition/v1";
15
16/// What kind of interaction this is. The kind is semantic, never a renderer
17/// choice: `modal` is a view decision (ADR C1), not a kind.
18#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
20#[serde(rename_all = "kebab-case")]
21#[non_exhaustive]
22pub enum InteractionKind {
23    /// Pick one of a fixed, displayed set — today's `Question<A>`.
24    ///
25    /// Not a two-option yes/no: that is [`InteractionKind::Confirm`].
26    Choice,
27    /// Free text.
28    Prompt,
29    // WHY THIS EXISTS BESIDE `Choice`, and why the boundary is enforced
30    // (#1912). A `//` comment, not a doc comment, DELIBERATELY: these docs are
31    // rendered into `schema/definition.schema.json` by schemars and read by
32    // non-Rust consumers, so the published description should state the
33    // contract and not this repo's incident history.
34    //
35    // Until the guard existed, the tree declared the same decision-shaped
36    // interaction under both kinds — `agentic::tools`'s mutation confirm and
37    // `interaction_adapter` and the permission builder as `Choice`,
38    // `interaction_form::confirm` as `Confirm`. C0c was the first slice to try
39    // to vary behaviour by the kind and had to go unconditional instead.
40    //
41    // `Confirm` is NOT redundant with `Choice`: a lone `ControlKind::Toggle`
42    // is a yes/no, and `Choice` means "pick one of a fixed, DISPLAYED set",
43    // which a toggle has none of. Two shapes, one intent, and the kind is what
44    // unifies them. See `InteractionDefinition::is_decision_shaped` for which
45    // direction is enforceable and why the other is not.
46    /// A yes/no decision — proceed or not.
47    ///
48    /// Carried either by a single toggle, or by one choice control offering
49    /// exactly two options where one grants and the other refuses or backs
50    /// out. The distinction from [`InteractionKind::Choice`] is the ROLES the
51    /// options carry, never how many there are.
52    Confirm,
53    /// A multi-field form.
54    Form,
55    /// A notice that carries no controls and expects no response.
56    Notice,
57}
58
59/// What a control MEANS, independent of how any surface draws it.
60#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
61#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
62#[serde(rename_all = "kebab-case")]
63#[non_exhaustive]
64pub enum SemanticRole {
65    /// Grants the thing being asked about.
66    Allow,
67    /// Refuses it. The fail-closed default for an absent answer.
68    Deny,
69    /// Backs out without deciding.
70    Cancel,
71    /// Ends the session.
72    Exit,
73    /// Supplies a value rather than a decision.
74    Value,
75}
76
77/// Whether something is REQUIRED to present or answer an interaction
78/// faithfully, or merely preferred.
79///
80/// This is the axis ADR law 5 turns on: *unknown REQUIRED behavior fails
81/// closed; unknown OPTIONAL behavior degrades visibly.* Without it a
82/// consumer that cannot satisfy a demand has only one move for both cases,
83/// and whichever it picks is wrong half the time — silently dropping a
84/// mandatory secret field, or refusing a document that merely wanted a
85/// diagram.
86///
87/// One vocabulary, deliberately: controls and surface features both use
88/// it, so a wire consumer learns the distinction once. On a control it
89/// means "the response must answer this FIELD" — which is only coherent
90/// because a choice is one field with many options, not many fields.
91#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
92#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
93#[serde(rename_all = "kebab-case")]
94pub enum Requirement {
95    /// Must be satisfiable. If it is not, the interaction is
96    /// `Unsupported` and no answer may be guessed.
97    Required,
98    /// Preferred. If it is not satisfiable, the interaction proceeds and
99    /// the shortfall is reported visibly.
100    Optional,
101}
102
103/// One mutually-exclusive option of a choice control.
104///
105/// The option carries the SemanticRole, not the control: a permission
106/// question has no single meaning — each of its options does. `allow once`
107/// means Allow, `deny (default)` means Deny, and they are options of one
108/// question rather than two questions.
109#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
110#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
111#[serde(deny_unknown_fields)]
112pub struct ChoiceOption {
113    /// Stable within the control; the definition commits to it.
114    pub id: OptionId,
115    /// What picking this option means.
116    pub role: SemanticRole,
117    /// Human-readable label. Labels never confer authority (ADR law 2).
118    pub label: String,
119    /// The accelerator a keyboard surface offers for this option — `a`,
120    /// `d`. Presentation, but NOT only presentation: the terminal
121    /// authorizes on it, so it is part of what the definition means and
122    /// cannot live in the view.
123    ///
124    /// Omitted when empty, so an option that offers no accelerator is
125    /// byte-identical to one written before this field existed.
126    #[serde(default, skip_serializing_if = "String::is_empty")]
127    pub key: String,
128    /// Hidden inputs that also select this option — `n`/`N` for a deny.
129    /// Never rendered, and never able to shadow another option's
130    /// canonical id or key: matching is canonical-first, and ambiguity
131    /// denies.
132    ///
133    /// Omitted when empty, for the same reason as `key`.
134    #[serde(default, skip_serializing_if = "Vec::is_empty")]
135    pub aliases: Vec<String>,
136}
137
138/// How much of a point in time a [`ControlKind::Temporal`] control asks for.
139///
140/// HTML's `date`, `time`, `datetime-local`, `month` and `week` — five input
141/// types that differ only in precision, so they are five configurations of one
142/// kind rather than five kinds.
143#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
144#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
145#[serde(rename_all = "kebab-case")]
146#[non_exhaustive]
147pub enum TemporalPrecision {
148    /// A calendar date: `YYYY-MM-DD`.
149    Date,
150    /// A wall-clock time: `HH:MM` or `HH:MM:SS`.
151    Time,
152    /// A local date and time: `YYYY-MM-DDTHH:MM[:SS]`.
153    DateTime,
154    /// A calendar month: `YYYY-MM`.
155    Month,
156    /// An ISO week: `YYYY-Www`.
157    Week,
158}
159
160impl TemporalPrecision {
161    /// The shape an answer must take, for a hint and for a refusal message.
162    #[must_use]
163    pub fn pattern(self) -> &'static str {
164        match self {
165            Self::Date => "YYYY-MM-DD",
166            Self::Time => "HH:MM[:SS]",
167            Self::DateTime => "YYYY-MM-DDTHH:MM[:SS]",
168            Self::Month => "YYYY-MM",
169            Self::Week => "YYYY-Www",
170        }
171    }
172}
173
174/// What a [`ControlKind::Path`] control points at.
175#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
176#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
177#[serde(rename_all = "kebab-case")]
178#[non_exhaustive]
179pub enum PathKind {
180    /// A single file.
181    File,
182    /// A directory.
183    Directory,
184}
185
186/// How a control accepts input.
187///
188/// `Choice` carries its options inline because they belong to it: one
189/// field, N mutually-exclusive answers. Modelling each option as its own
190/// control would make [`Requirement`] incoherent — under "the response
191/// must include this control", answering an allow would require also
192/// answering the deny.
193///
194/// # A kind is a MEANING; an affordance is not a kind
195///
196/// The line this vocabulary is drawn on, because the HTML input catalog it
197/// mirrors does not draw it and copying that list variant-for-variant would
198/// have produced twenty-two.
199///
200/// A kind earns its place when it changes what a well-formed ANSWER is —
201/// `#c0ffee` is a color and not a date, and no surface may accept it as one.
202/// It does not earn a place merely by suggesting a different widget: a slider
203/// and a typed number accept the same answers, so [`Range`](Self::Range) is
204/// separate from [`Number`](Self::Number) only because a bounded-and-stepped
205/// value space IS different (its bounds are mandatory), not because one draws
206/// as a slider.
207///
208/// What this excludes, deliberately:
209///
210/// - `button`, `submit`, `reset`, `image` — form ACTIONS. A definition's
211///   actions are its [`SemanticRole`]s; a submit button that carried its own
212///   meaning would be a second authority vocabulary.
213/// - `hidden` — not a control at all. A value the operator cannot see is not
214///   a thing they answered, and putting one here would let a surface claim an
215///   answer nobody gave.
216/// - `radio`, `checkbox`, `password`, `select` — already spelled
217///   [`Choice`](Self::Choice), [`Toggle`](Self::Toggle),
218///   [`Secret`](Self::Secret) and [`Choice`](Self::Choice) again. `<select>`
219///   and a radio group are one meaning with two presentations.
220///
221/// # Every kind here is answerable by typing
222///
223/// None of these mint a [`SurfaceFeature`] demand (see
224/// `downgrade::intrinsic_demands`), and that is deliberate rather than
225/// unfinished. A date answered by typing `2026-09-01` is a satisfied date, so
226/// a surface with no calendar widget has NOT failed to present the question —
227/// it presented it plainly. Only [`Secret`](Self::Secret) demands a feature,
228/// because it changes what may be DISCLOSED rather than how something is
229/// drawn. Minting a demand per kind would make plain and headless surfaces
230/// refuse questions they can answer perfectly well, which is law 5 pointed at
231/// the wrong axis.
232#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
233#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
234#[serde(rename_all = "kebab-case", deny_unknown_fields)]
235#[non_exhaustive]
236pub enum ControlKind {
237    /// Pick exactly one of the options offered.
238    Choice {
239        /// The options, in presentation order.
240        options: Vec<ChoiceOption>,
241    },
242    /// Free text.
243    ///
244    /// **Deliberately still a unit variant.** HTML spells five flavours of
245    /// text — `email`, `url`, `tel`, `search`, plain — and carrying one here
246    /// as `Text { format }` would re-encode the most common kind in the
247    /// vocabulary: a unit variant is the dag-cbor string `"text"`, a struct
248    /// variant is a map. That breaks `tests/data/interaction-vectors.json`,
249    /// whose frozen canonical bytes are DECODED rather than rebuilt, and the
250    /// external-consumer corpus answering them — the compatibility fixtures
251    /// CLAUDE.md names as what a rewrite is written against.
252    ///
253    /// The flavours differ, in a terminal, only in what counts as valid. That
254    /// is not worth re-addressing every definition ever written. If one earns
255    /// its own kind later it can be ADDED as a new variant, which breaks
256    /// nothing — the way `Number`, `Temporal`, `Color` and `Path` were.
257    Text,
258    /// A boolean.
259    Toggle,
260    /// A secret: never persisted in markup or logs (ADR D1).
261    Secret,
262    /// A whole number, optionally bounded and stepped.
263    ///
264    /// **Integers, and that is a decision.** A binary float cannot be
265    /// canonically encoded without deciding what to do about `NaN` and `-0.0`,
266    /// and this vocabulary is content-addressed — two encodings of one value
267    /// would be two identities. Every number newt asks for today (a round cap,
268    /// a port, a timeout, a percentage) is integral. A fractional control is a
269    /// later, separate decision, and it will carry its digits as a decimal
270    /// STRING rather than reopening this one.
271    Number {
272        /// Smallest acceptable value, inclusive.
273        #[serde(default, skip_serializing_if = "Option::is_none")]
274        min: Option<i64>,
275        /// Largest acceptable value, inclusive.
276        #[serde(default, skip_serializing_if = "Option::is_none")]
277        max: Option<i64>,
278        /// The grid the value must sit on, counted from `min` (or from zero
279        /// when unbounded below). `None` accepts any integer in range.
280        #[serde(default, skip_serializing_if = "Option::is_none")]
281        step: Option<i64>,
282    },
283    /// A number on a bounded, stepped scale — HTML's `range`.
284    ///
285    /// Distinct from [`Number`](Self::Number) because its bounds are
286    /// MANDATORY: a slider without ends is not a slider, and a surface that
287    /// draws one needs both to draw anything at all.
288    Range {
289        /// Smallest acceptable value, inclusive.
290        min: i64,
291        /// Largest acceptable value, inclusive.
292        max: i64,
293        /// The grid the value must sit on, counted from `min`.
294        step: i64,
295    },
296    /// A point in time at the given precision.
297    Temporal {
298        /// How much of a moment is being asked for.
299        precision: TemporalPrecision,
300    },
301    /// An sRGB color, written `#rrggbb`.
302    Color,
303    /// A filesystem path.
304    ///
305    /// The protocol says what is being NAMED, never whether it exists: an
306    /// existence check is the host's business, is racy by nature, and would
307    /// make a definition's validity depend on the machine reading it.
308    Path {
309        /// Whether the path names a file or a directory.
310        kind: PathKind,
311        /// Extensions or globs a surface may filter by — HTML's `accept`.
312        /// Advisory: a surface that ignores it still collects a valid answer.
313        #[serde(default, skip_serializing_if = "Vec::is_empty")]
314        accept: Vec<String>,
315    },
316}
317
318impl ControlKind {
319    /// Is `text` a well-formed answer to this control?
320    ///
321    /// `Err` carries what was EXPECTED, phrased for a human — "an integer in
322    /// 1..=10000", "YYYY-MM-DD", "#rrggbb". The caller decides what a
323    /// malformed answer means: the binding layer refuses it, an interactive
324    /// surface can show it as you type. One implementation either way, which
325    /// is the point — a panel that validated `Number` its own way would be a
326    /// second opinion about what the definition means.
327    ///
328    /// [`Choice`](Self::Choice) always answers `Ok`: typed input for a choice
329    /// is RESOLVED (canonical-first, aliases second, ambiguity denies) by
330    /// `binding::resolve_typed`, and a second, weaker check here is exactly
331    /// the duplicate that rule exists to prevent. [`Toggle`](Self::Toggle) and
332    /// [`Secret`](Self::Secret) do not travel as text at all.
333    ///
334    /// # Errors
335    ///
336    /// The expectation this control's answers must meet, when `text` does not.
337    pub fn check_text(&self, text: &str) -> Result<(), String> {
338        let text = text.trim();
339        match self {
340            Self::Choice { .. } | Self::Toggle | Self::Secret => Ok(()),
341            // Free text: anything is well-formed. A FLAVOUR of text (email,
342            // url, tel) is not modelled here — see the note on `Text`.
343            Self::Text => Ok(()),
344            Self::Number { min, max, step } => {
345                check_integer(text, *min, *max, *step, &describe_number(*min, *max, *step))
346            }
347            Self::Range { min, max, step } => check_integer(
348                text,
349                Some(*min),
350                Some(*max),
351                Some(*step),
352                &describe_number(Some(*min), Some(*max), Some(*step)),
353            ),
354            Self::Temporal { precision } => check_temporal(text, *precision),
355            Self::Color => check_color(text),
356            // Existence is the host's business and is racy by nature; the
357            // protocol only asks that a path was actually named.
358            Self::Path { .. } => (!text.is_empty())
359                .then_some(())
360                .ok_or_else(|| "a path".to_string()),
361        }
362    }
363
364    /// What this kind advertises after a control's label — ` [y/n]`,
365    /// ` [YYYY-MM-DD]`, ` (secret, not echoed)`.
366    ///
367    /// **One table, not one per surface.** The plain projection and the
368    /// RichTUI view model each carried this suffix list, which is two places
369    /// for one piece of knowledge and the shape the reuse discipline exists to
370    /// stop: a kind added to one and forgotten in the other renders as a bare
371    /// label on that surface, silently. Here a new kind advertises itself
372    /// everywhere by construction.
373    ///
374    /// The strings for the pre-existing kinds are EXACT: the plain projection
375    /// is a byte-identity contract, so `Toggle` is ` [y/n]` — never `[y/N]`,
376    /// because a rendered default is how a headless surface chooses one by
377    /// accident.
378    ///
379    /// [`Choice`](Self::Choice) advertises nothing here: its options are
380    /// rendered as their own lines, not as a suffix.
381    #[must_use]
382    pub fn hint(&self) -> String {
383        match self {
384            Self::Choice { .. } => String::new(),
385            Self::Text => String::new(),
386            Self::Toggle => " [y/n]".to_string(),
387            Self::Secret => " (secret, not echoed)".to_string(),
388            Self::Number { min, max, step } => match (min, max) {
389                (None, None) => " [number]".to_string(),
390                _ => format!(" [{}]", describe_number(*min, *max, *step)),
391            },
392            Self::Range { min, max, .. } => format!(" [{min}..={max}]"),
393            Self::Temporal { precision } => format!(" [{}]", precision.pattern()),
394            Self::Color => " [#rrggbb]".to_string(),
395            Self::Path { kind, .. } => match kind {
396                PathKind::File => " [file path]".to_string(),
397                PathKind::Directory => " [directory path]".to_string(),
398            },
399        }
400    }
401
402    /// Whether an answer to this control travels as `ControlValue::Text`.
403    ///
404    /// True for every value-shaped kind — text, number, range, temporal,
405    /// color, path. False for the three that have their own value shape: a
406    /// choice names an option, a toggle carries a bool, and a secret carries a
407    /// REFERENCE (there is deliberately no variant that can hold plaintext).
408    #[must_use]
409    pub fn travels_as_text(&self) -> bool {
410        matches!(
411            self,
412            Self::Text
413                | Self::Number { .. }
414                | Self::Range { .. }
415                | Self::Temporal { .. }
416                | Self::Color
417                | Self::Path { .. }
418        )
419    }
420}
421
422/// One control: a stable id, what it means, and how it takes input.
423#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
424#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
425#[serde(deny_unknown_fields)]
426pub struct Control {
427    /// Stable within the definition; the definition commits to it.
428    pub id: ControlId,
429    /// How it accepts input, and — for a choice — what it offers.
430    pub kind: ControlKind,
431    /// Human-readable label. Labels never confer authority (ADR law 2).
432    pub label: String,
433    /// Whether a response must include this control.
434    pub requirement: Requirement,
435}
436
437/// A capability a surface must have to present some part of an
438/// interaction: accepting secret input without echoing it, rendering a
439/// diagram, showing more than one control at once.
440///
441/// A named string rather than a closed enum, on purpose. This is a WIRE
442/// vocabulary: a v2 document may demand a feature this build has never
443/// heard of, and a closed enum could not even represent it — the name
444/// would be lost at parse time and law 5 would have nothing left to act
445/// on. Carrying the name verbatim is what lets an unknown demand be
446/// refused (when required) or reported (when optional) instead of
447/// silently vanishing.
448#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
449// See the note on `ControlId`: deserialization goes through the
450// constructor. An UNRECOGNIZED feature name is still valid — that is the
451// forward-compatibility case — but an EMPTY one is not.
452#[serde(into = "String", try_from = "String")]
453pub struct SurfaceFeature(String);
454
455#[cfg(feature = "schema")]
456crate::string_scalar_schema!(SurfaceFeature, None::<&str>);
457
458impl From<SurfaceFeature> for String {
459    fn from(value: SurfaceFeature) -> Self {
460        value.0
461    }
462}
463
464impl TryFrom<String> for SurfaceFeature {
465    type Error = ProtocolError;
466
467    fn try_from(value: String) -> Result<Self, Self::Error> {
468        Self::new(value)
469    }
470}
471
472impl SurfaceFeature {
473    /// The surface accepts secret input without echoing or persisting it.
474    pub const SECRET_INPUT: &'static str = "secret-input";
475    /// The surface renders a diagram extension (epic slice E0).
476    pub const DIAGRAMS: &'static str = "diagrams";
477    /// The surface can present more than one control at once.
478    pub const MULTI_CONTROL: &'static str = "multi-control";
479
480    /// Every feature name this build understands.
481    pub const KNOWN: &'static [&'static str] =
482        &[Self::SECRET_INPUT, Self::DIAGRAMS, Self::MULTI_CONTROL];
483
484    /// Adopt a feature name.
485    ///
486    /// # Errors
487    ///
488    /// [`ProtocolError::InvalidId`] when empty. An UNRECOGNIZED name is
489    /// not an error — that is the forward-compatibility case, and
490    /// [`is_known`](Self::is_known) is how a consumer asks.
491    pub fn new(name: impl Into<String>) -> Result<Self, ProtocolError> {
492        let name = name.into();
493        if name.is_empty() {
494            return Err(ProtocolError::InvalidId {
495                kind: "surface feature",
496                reason: "must not be empty".to_string(),
497            });
498        }
499        Ok(Self(name))
500    }
501
502    /// The name as written.
503    #[must_use]
504    pub fn as_str(&self) -> &str {
505        &self.0
506    }
507
508    /// Whether this build understands the feature.
509    #[must_use]
510    pub fn is_known(&self) -> bool {
511        Self::KNOWN.contains(&self.0.as_str())
512    }
513}
514
515/// A definition's demand for one surface feature, and how hard a demand it
516/// is.
517#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
518#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
519#[serde(deny_unknown_fields)]
520pub struct FeatureDemand {
521    /// Which capability.
522    pub feature: SurfaceFeature,
523    /// Required, or merely preferred.
524    pub requirement: Requirement,
525}
526
527/// The immutable semantic model of one interaction.
528///
529/// Identity is a [`DefinitionId`] — a `ContentId` over the canonical
530/// encoding of this whole record, which is also its **exact form digest**.
531#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
532#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
533#[serde(deny_unknown_fields)]
534pub struct InteractionDefinition {
535    /// Versioned type tag; see [`DEFINITION_SCHEMA_V1`].
536    pub schema: crate::tag::DefinitionTag,
537    /// What kind of interaction this is.
538    pub kind: InteractionKind,
539    /// Which revision of this definition's authoring lineage this is.
540    pub revision: Revision,
541    /// The readable body — canonical Markdown, the fallback every surface
542    /// can render (ADR law 1). Held as text; parsing belongs to
543    /// `newt_core::markup`, not to the protocol layer.
544    pub markdown: String,
545    /// The controls, in presentation order.
546    pub controls: Vec<Control>,
547    /// What a surface must be able to do to present this faithfully, each
548    /// demand marked required or optional.
549    pub features: Vec<FeatureDemand>,
550    /// A subordinate line beneath the body — a control hint, a danger
551    /// warning. Readable content like `markdown`, kept separate because
552    /// surfaces place it differently and the legacy prompt already
553    /// distinguishes the two.
554    ///
555    /// Omitted when absent, so a definition without one is byte-identical
556    /// to one written before this field existed.
557    #[serde(default, skip_serializing_if = "Option::is_none")]
558    pub note: Option<String>,
559}
560
561/// Whether `controls` can ONLY be a binary decision — see
562/// [`InteractionDefinition::is_decision_shaped`], which delegates here.
563///
564/// Free-standing so a caller that is still ASSEMBLING a definition can pick
565/// the kind from the controls it has just built, without duplicating the rule.
566/// `interaction_adapter` is that caller: it converts a legacy `Question` whose
567/// action count it does not know in advance, and hardcoding a kind there is
568/// what made the adapter emit decision-shaped definitions labelled `Choice`
569/// (#1912). One rule, two callers, and the guard in
570/// [`InteractionDefinition::new`] holds them to it.
571#[must_use]
572pub fn controls_are_decision_shaped(controls: &[Control]) -> bool {
573    let [control] = controls else {
574        return false;
575    };
576    let ControlKind::Choice { options } = &control.kind else {
577        return false;
578    };
579    let [a, b] = options.as_slice() else {
580        return false;
581    };
582    let decides = |r: SemanticRole| matches!(r, SemanticRole::Deny | SemanticRole::Cancel);
583    (a.role == SemanticRole::Allow && decides(b.role))
584        || (b.role == SemanticRole::Allow && decides(a.role))
585}
586
587impl InteractionDefinition {
588    /// Whether this definition can ONLY be a binary decision: one
589    /// [`ControlKind::Choice`] control offering exactly two options, one
590    /// granting and the other refusing or backing out.
591    ///
592    /// Keys on [`SemanticRole`], never on the option count. `Allow` + `Deny`
593    /// is a decision, `Allow` + `Cancel` is a decision that offers a way out,
594    /// and `Value` + `Value` is a two-way pick and not a decision at all.
595    ///
596    /// **Deliberately narrower than "is a yes/no", and the gap is the point.**
597    /// A lone [`ControlKind::Toggle`] is also a yes/no — and it is the case
598    /// that settles #1912, because [`InteractionKind::Choice`] means "pick one
599    /// of a fixed, DISPLAYED set" and a toggle displays no set, so `Choice`
600    /// cannot describe it and `Confirm` is not redundant with it.
601    ///
602    /// But a lone toggle is ALSO a one-field form — "remember this? [ ]" — and
603    /// nothing in the shape says which. So the toggle case is admitted by
604    /// [`InteractionKind::Confirm`] and never *required* of it. This predicate
605    /// reports only what a shape can prove, which is why the guard in
606    /// [`InteractionDefinition::new`] enforces one direction and not both.
607    #[must_use]
608    pub fn is_decision_shaped(&self) -> bool {
609        controls_are_decision_shaped(&self.controls)
610    }
611
612    /// Whether a lone [`ControlKind::Toggle`] carries this definition — the
613    /// other shape [`InteractionKind::Confirm`] may take.
614    #[must_use]
615    fn is_lone_toggle(&self) -> bool {
616        matches!(self.controls.as_slice(), [c] if matches!(c.kind, ControlKind::Toggle))
617    }
618
619    /// Build a definition carrying the current schema tag.
620    #[must_use]
621    pub fn new(kind: InteractionKind, markdown: impl Into<String>, controls: Vec<Control>) -> Self {
622        let built = Self {
623            schema: crate::tag::DefinitionTag,
624            kind,
625            revision: Revision::FIRST,
626            markdown: markdown.into(),
627            controls,
628            features: Vec::new(),
629            note: None,
630        };
631        // **THE GUARD (#1912).** A doc comment saying "use Confirm for yes/no"
632        // is not a guard — that is exactly what the tree had, and it drifted
633        // in the one direction nobody was watching. This fires in every debug
634        // build, so a SECOND constructor cannot reintroduce the ambiguity
635        // without a test going red the first time it runs.
636        //
637        // `debug_assert` rather than a `Result`: the pairing is a property of
638        // the code that constructs the definition, fixable at the call site
639        // and never at runtime. Making `new` fallible would push a
640        // `.expect()` onto ~15 infallible call sites to catch a bug none of
641        // them can have in production.
642        // The direction #1912 is about: a decision-shaped definition declared
643        // as anything else is the ambiguity itself.
644        debug_assert!(
645            !built.is_decision_shaped() || kind == InteractionKind::Confirm,
646            "a binary decision — one Toggle, or one Choice control offering \
647             two options where one grants and the other refuses — is \
648             InteractionKind::Confirm. See that variant's doc. Got kind \
649             {kind:?} for controls {:?}",
650            built.controls
651        );
652        // And the converse, SCOPED TO DEFINITIONS THAT HAVE CONTROLS. The
653        // unscoped form looked tidier and was wrong: it fired immediately on
654        // control-less `Confirm` fixtures across four newt-interaction tests,
655        // which are building a definition to exercise something else and are
656        // not claiming a shape at all. Policing an empty definition's kind is
657        // not this guard's business; policing a populated one that is NOT a
658        // yes/no still is — a five-option `Confirm` is mislabelled too.
659        debug_assert!(
660            kind != InteractionKind::Confirm
661                || built.controls.is_empty()
662                || built.is_decision_shaped()
663                || built.is_lone_toggle(),
664            "InteractionKind::Confirm carries a binary decision (a Toggle, or \
665             two options one of which grants) or no controls at all; got \
666             controls {:?}",
667            built.controls
668        );
669        built
670    }
671
672    /// This definition's identity, which is also its exact form digest.
673    ///
674    /// # Errors
675    ///
676    /// Propagates a canonical-encoding failure.
677    pub fn definition_id(&self) -> Result<DefinitionId, ProtocolError> {
678        Ok(DefinitionId::from_content_id(self.content_id()?))
679    }
680
681    /// The schema tag, as a string.
682    ///
683    /// Reading it needs no check: the tag is a TYPE that deserializes from
684    /// exactly one value, so a record of this type cannot carry any other
685    /// one. What used to be a runtime `ensure_known_schema` is now a thing
686    /// the wire cannot express — and the published schema says so with a
687    /// `const`, so a foreign implementor validating against the wrong
688    /// record's schema fails instead of passing.
689    #[must_use]
690    pub fn schema_tag(&self) -> &'static str {
691        self.schema.as_str()
692    }
693}
694
695impl ContentAddressable for InteractionDefinition {
696    fn canonical_form(&self) -> Result<Vec<u8>, ContentError> {
697        // The canonical form covers the WHOLE record — schema tag, kind,
698        // revision, markdown, controls, and surface features. A form over a
699        // hand-picked subset would let two definitions that differ in a
700        // skipped field share one id, and an "exact form digest" that is not
701        // exact is worse than none.
702        canonical::to_canonical_dagcbor(self)
703    }
704}
705
706/// How a number control's value space reads in a refusal or a hint.
707fn describe_number(min: Option<i64>, max: Option<i64>, step: Option<i64>) -> String {
708    let range = match (min, max) {
709        (Some(min), Some(max)) => format!("an integer in {min}..={max}"),
710        (Some(min), None) => format!("an integer {min} or greater"),
711        (None, Some(max)) => format!("an integer {max} or less"),
712        (None, None) => "an integer".to_string(),
713    };
714    match step.filter(|s| *s > 1) {
715        Some(step) => format!("{range}, in steps of {step}"),
716        None => range,
717    }
718}
719
720/// The shared integer check behind `Number` and `Range`.
721///
722/// Two hostile-definition hazards, both made unrepresentable rather than
723/// caught. A `step` of zero or less is treated as "any integer in range": the
724/// definition may come from untrusted markup, and dividing by a hostile zero
725/// is a panic in a parser, while refusing every answer would hand its author a
726/// denial of service. And the grid offset is computed in `i128`, because
727/// `value - min` overflows `i64` for a control spanning the full range — a
728/// `checked_sub` there would refuse `i64::MAX` as malformed when it is
729/// perfectly in range and on grid. The wider type has no such edge to get
730/// wrong.
731fn check_integer(
732    text: &str,
733    min: Option<i64>,
734    max: Option<i64>,
735    step: Option<i64>,
736    expected: &str,
737) -> Result<(), String> {
738    let value: i64 = text.parse().map_err(|_| expected.to_string())?;
739    if min.is_some_and(|min| value < min) || max.is_some_and(|max| value > max) {
740        return Err(expected.to_string());
741    }
742    if let Some(step) = step.filter(|s| *s > 1) {
743        let base = i128::from(min.unwrap_or(0));
744        let offset = i128::from(value) - base;
745        if offset.rem_euclid(i128::from(step)) != 0 {
746            return Err(expected.to_string());
747        }
748    }
749    Ok(())
750}
751
752/// `#rrggbb`, sRGB, lowercase or upper.
753fn check_color(text: &str) -> Result<(), String> {
754    let expected = || "#rrggbb".to_string();
755    let hex = text.strip_prefix('#').ok_or_else(expected)?;
756    (hex.len() == 6 && hex.bytes().all(|b| b.is_ascii_hexdigit()))
757        .then_some(())
758        .ok_or_else(expected)
759}
760
761/// A point in time, to the precision asked for.
762///
763/// Shape and field ranges, NOT calendar truth: `2026-02-31` passes here. A
764/// calendar is a host concern (and a leap-second argument), and this
765/// vocabulary's job is to refuse an answer that is not a date AT ALL.
766fn check_temporal(text: &str, precision: TemporalPrecision) -> Result<(), String> {
767    let expected = || precision.pattern().to_string();
768    let (date, time) = match precision {
769        TemporalPrecision::DateTime => {
770            let (d, t) = text.split_once('T').ok_or_else(expected)?;
771            (Some(d), Some(t))
772        }
773        TemporalPrecision::Time => (None, Some(text)),
774        _ => (Some(text), None),
775    };
776    if let Some(date) = date {
777        let mut parts = date.split('-');
778        let year = parts.next().ok_or_else(expected)?;
779        if year.len() != 4 || !year.bytes().all(|b| b.is_ascii_digit()) {
780            return Err(expected());
781        }
782        match precision {
783            TemporalPrecision::Week => {
784                let week = parts.next().ok_or_else(expected)?;
785                let n = week.strip_prefix('W').ok_or_else(expected)?;
786                number_in(n, 2, 1, 53).ok_or_else(expected)?;
787            }
788            TemporalPrecision::Month => {
789                number_in(parts.next().ok_or_else(expected)?, 2, 1, 12).ok_or_else(expected)?;
790            }
791            _ => {
792                number_in(parts.next().ok_or_else(expected)?, 2, 1, 12).ok_or_else(expected)?;
793                number_in(parts.next().ok_or_else(expected)?, 2, 1, 31).ok_or_else(expected)?;
794            }
795        }
796        if parts.next().is_some() {
797            return Err(expected());
798        }
799    }
800    if let Some(time) = time {
801        let mut parts = time.split(':');
802        number_in(parts.next().ok_or_else(expected)?, 2, 0, 23).ok_or_else(expected)?;
803        number_in(parts.next().ok_or_else(expected)?, 2, 0, 59).ok_or_else(expected)?;
804        // Seconds are optional at every precision that has a time at all.
805        if let Some(seconds) = parts.next() {
806            number_in(seconds, 2, 0, 60).ok_or_else(expected)?;
807        }
808        if parts.next().is_some() {
809            return Err(expected());
810        }
811    }
812    Ok(())
813}
814
815/// A fixed-width, zero-padded decimal field within `lo..=hi`.
816fn number_in(field: &str, width: usize, lo: u32, hi: u32) -> Option<u32> {
817    if field.len() != width || !field.bytes().all(|b| b.is_ascii_digit()) {
818        return None;
819    }
820    field.parse::<u32>().ok().filter(|n| (lo..=hi).contains(n))
821}