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}