Skip to main content

newt_interaction/
binding.rs

1//! **Fail-closed response validation** (A3, #1837).
2//!
3//! Pure rules over types this crate already owns — the same shape as
4//! [`plan_presentation`](crate::plan_presentation), and for the same
5//! reason: none of it touches SQLite, a TTY, or a socket, so none of it
6//! belongs in a crate that does. Keeping the security-relevant logic in
7//! the dependency-minimal layer is what makes it auditable.
8//!
9//! **Validation is ATOMIC.** A response is accepted as a whole or refused
10//! as a whole. There is no partial acceptance and no "apply the controls
11//! that checked out" path, because a caller handed half an answer would
12//! have to decide what the other half meant — which is exactly the
13//! per-surface drift this epic exists to end. It is also what keeps the
14//! Lean `authorize` / `hidden_action_rejected` generalization honest: a
15//! hidden or unoffered control cannot ride along beside a valid one.
16//!
17//! **Actions map only to caller-registered handlers.** Nothing in a
18//! definition is executable. A definition carries markdown, labels, and
19//! option ids authored by — possibly — untrusted markup, and this module
20//! never turns any of it into a command, a URL, a tool name, a path, a
21//! topic, or a caveat. The accepted outcome names a
22//! [`HandlerId`](crate::binding::HandlerId) the CALLER registered, and
23//! nothing else. A registry owned down here would be a second capability
24//! system growing beside the store (epic law 13); authority-minting stays
25//! above this layer.
26
27use thiserror::Error;
28
29use crate::definition::{ChoiceOption, Control, ControlKind, InteractionDefinition, Requirement};
30use crate::error::ProtocolError;
31use crate::ids::{ControlId, InstanceId, OptionId, ResponseId};
32use crate::instance::{Audience, InteractionInstance, LifecycleState};
33use crate::lifecycle::Lifecycle;
34use crate::response::{AssertionKind, ControlValue, Response};
35
36/// A caller-supplied handler name.
37///
38/// Opaque to this crate: it routes to something the CALLER holds. This
39/// layer never dereferences it, never interprets it, and never derives
40/// one from a definition.
41#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
42pub struct HandlerId(String);
43
44impl HandlerId {
45    /// Adopt a handler name.
46    ///
47    /// # Errors
48    ///
49    /// [`ProtocolError::InvalidId`] when empty — a handler that names
50    /// nothing cannot be routed to.
51    pub fn new(name: impl Into<String>) -> Result<Self, ProtocolError> {
52        let name = name.into();
53        if name.is_empty() {
54            return Err(ProtocolError::InvalidId {
55                kind: "handler id",
56                reason: "must not be empty".to_string(),
57            });
58        }
59        Ok(Self(name))
60    }
61
62    /// The name as adopted.
63    #[must_use]
64    pub fn as_str(&self) -> &str {
65        &self.0
66    }
67}
68
69/// One action the CALLER has registered, and who may invoke it.
70///
71/// The caller injects these. A definition cannot add one, which is the
72/// property that keeps authored markup from naming an executable thing.
73#[derive(Debug, Clone, PartialEq, Eq)]
74pub struct RegisteredAction {
75    /// The option this action answers to.
76    pub option: OptionId,
77    /// What the caller will run.
78    pub handler: HandlerId,
79    /// Which audiences may invoke it. An audience the offer admits is not
80    /// automatically an audience every ACTION admits.
81    pub audiences: Vec<Audience>,
82}
83
84/// What the caller brings to a validation: its fence and its handlers.
85#[derive(Debug, Clone, Copy)]
86pub struct ResponderContext<'a> {
87    /// The workspace fence the caller is operating in.
88    pub workspace_key: &'a str,
89    /// The actions the caller has registered.
90    pub registered: &'a [RegisteredAction],
91}
92
93/// One accepted submission, resolved to the handler the caller registered.
94#[derive(Debug, Clone, PartialEq, Eq)]
95pub struct ResolvedAction {
96    /// Which control was answered.
97    pub control: ControlId,
98    /// Which option was chosen.
99    pub option: OptionId,
100    /// The CALLER's handler. Never derived from the definition.
101    pub handler: HandlerId,
102}
103
104/// A response that passed every rule, with its actions resolved.
105///
106/// Carries ids and caller-registered handler names only. Deliberately no
107/// markdown, label, note, or any other author-supplied string: an
108/// outcome that carried document text would invite a caller to act on it.
109#[derive(Debug, Clone, PartialEq, Eq)]
110pub struct Accepted {
111    /// The response that was accepted.
112    pub response: ResponseId,
113    /// The offer it resolves.
114    pub instance: InstanceId,
115    /// The actions to run, in submission order.
116    pub actions: Vec<ResolvedAction>,
117}
118
119/// Why a response was refused. Every variant is a REFUSAL: there is no
120/// variant meaning "accepted with reservations".
121#[derive(Debug, Error)]
122pub enum Refusal {
123    /// The offer is not open for answers.
124    #[error("the offer is {state:?}, not Published; it accepts no response")]
125    NotPublished {
126        /// The state the offer is actually in.
127        state: LifecycleState,
128    },
129    /// The response answers a different offer.
130    #[error("the response answers instance `{named}`, but this offer is `{actual}`")]
131    InstanceMismatch {
132        /// What the response named.
133        named: String,
134        /// The offer being resolved.
135        actual: String,
136    },
137    /// The response answers a different definition than the offer binds.
138    #[error("the response answers definition `{named}`, but the offer binds `{offered}`")]
139    DefinitionMismatch {
140        /// What the response named.
141        named: String,
142        /// What the offer binds.
143        offered: String,
144    },
145    /// The definition presented is not the one the response was minted
146    /// against — it changed by at least one byte.
147    #[error("definition digest mismatch: the response binds `{bound}`, the presented form is `{presented}`")]
148    DigestMismatch {
149        /// The digest the response binds.
150        bound: String,
151        /// The digest of the definition actually presented.
152        presented: String,
153    },
154    /// The responder answered an older revision.
155    #[error("the response is against revision {offered}, but the definition is at {current}")]
156    StaleRevision {
157        /// The revision the responder saw.
158        offered: u64,
159        /// The revision the definition is at now.
160        current: u64,
161    },
162    /// The offer belongs to a different workspace fence.
163    #[error(
164        "workspace fence mismatch: the offer is fenced to `{offered}`, the caller is in `{caller}`"
165    )]
166    WorkspaceMismatch {
167        /// The fence on the offer.
168        offered: String,
169        /// The caller's fence.
170        caller: String,
171    },
172    /// The responder answered from an audience the offer is not open to.
173    #[error("this offer is not open to the {audience:?} audience")]
174    AudienceMismatch {
175        /// The audience that answered.
176        audience: Audience,
177    },
178    /// The offer requires an authenticated assertion and none was
179    /// presented.
180    #[error("this offer requires an authenticated assertion")]
181    AssertionRequired,
182    /// The response answers a control the definition does not offer.
183    #[error("`{control}` is not a control of this definition")]
184    ExtraControl {
185        /// The control the response named.
186        control: String,
187    },
188    /// The response answers one control twice.
189    #[error("`{control}` is answered more than once")]
190    DuplicateControl {
191        /// The control answered twice.
192        control: String,
193    },
194    /// A REQUIRED control has no answer.
195    #[error("required control `{control}` has no answer")]
196    MissingRequiredControl {
197        /// The unanswered control.
198        control: String,
199    },
200    /// The value's kind does not match the control's kind.
201    #[error("control `{control}` is {expected}, but the value is {found}")]
202    WrongControlType {
203        /// The control answered.
204        control: String,
205        /// The kind the definition declares.
206        expected: &'static str,
207        /// The kind the value carries.
208        found: &'static str,
209    },
210    /// The value is the control's kind, but not a well-formed one of it —
211    /// `not-a-date` for a date, `11` for a number capped at 10.
212    ///
213    /// Distinct from [`WrongControlType`](Self::WrongControlType) on purpose:
214    /// that one is a responder answering the wrong FIELD SHAPE, which is a
215    /// protocol error, while this is a human typing something that is not what
216    /// was asked for. A surface can correct the second inline and can do
217    /// nothing about the first.
218    #[error("control `{control}` expects {expected}, but got `{found}`")]
219    MalformedValue {
220        /// The control answered.
221        control: String,
222        /// What a well-formed answer looks like.
223        expected: String,
224        /// What was submitted.
225        found: String,
226    },
227    /// The chosen option is not one the control offers.
228    #[error("control `{control}` does not offer option `{option}`")]
229    UnknownOption {
230        /// The control answered.
231        control: String,
232        /// The option chosen.
233        option: String,
234    },
235    /// The chosen option maps to no caller-registered handler.
236    #[error("option `{option}` maps to no registered handler")]
237    UnknownAction {
238        /// The option chosen.
239        option: String,
240    },
241    /// The action exists but this responder may not invoke it.
242    #[error("option `{option}` is not eligible for the {audience:?} audience")]
243    ActionNotEligible {
244        /// The option chosen.
245        option: String,
246        /// The audience that answered.
247        audience: Audience,
248    },
249    /// Content addressing failed while checking a binding.
250    #[error(transparent)]
251    Protocol(#[from] ProtocolError),
252}
253
254/// The name of a control's kind, for a refusal message.
255fn kind_name(kind: &ControlKind) -> &'static str {
256    match kind {
257        ControlKind::Choice { .. } => "choice",
258        ControlKind::Text => "text",
259        ControlKind::Toggle => "toggle",
260        ControlKind::Secret => "secret",
261        ControlKind::Number { .. } => "number",
262        ControlKind::Range { .. } => "range",
263        ControlKind::Temporal { .. } => "temporal",
264        ControlKind::Color => "color",
265        ControlKind::Path { .. } => "path",
266    }
267}
268
269/// The name of a value's kind, for a refusal message.
270fn value_name(value: &ControlValue) -> &'static str {
271    match value {
272        ControlValue::Choice { .. } => "choice",
273        ControlValue::Text { .. } => "text",
274        ControlValue::Toggle { .. } => "toggle",
275        ControlValue::Secret { .. } => "secret",
276    }
277}
278
279/// Check one submission against the control it claims to answer.
280///
281/// Returns the chosen option when the control is a choice, so the caller
282/// can resolve it to a handler; `None` for the value-carrying kinds,
283/// which route to nothing.
284/// Resolve TYPED input to the option it names, or refuse.
285///
286/// **The one implementation of the canonical-first / alias / ambiguity-denial
287/// rules.** It lived in `newt_core::tty::Question::parse`, over the legacy
288/// `Action` list, and D0 (#1878) moved it here rather than writing a second
289/// one against `ChoiceOption` — which is the third answer-parser this epic's
290/// deletion gate exists to prevent.
291///
292/// It belongs beside [`check_value`] because the two are the same question
293/// asked at different distances: `check_value` resolves a submission that
294/// already names an `OptionId`, this resolves the text a human typed. Both
295/// now live in one module, so "how is an answer resolved" has one answer.
296///
297/// ## The rules, unchanged
298///
299/// * **Canonical first.** An option's `id` (its wire name) or its `key` (the
300///   accelerator) match before any alias is considered. The permission menu is
301///   case-distinct on purpose — `a` is allow-once and `A` is allow-permanent —
302///   so folding case, or letting an alias shadow a canonical key, would let a
303///   weaker answer select a stronger grant.
304/// * **Aliases second**, and only when nothing canonical matched.
305/// * **Ambiguity refuses, at either tier.** Two matches is not a reason to
306///   pick one.
307///
308/// ## Refusal is `None`, and what it MEANS is the caller's
309///
310/// This deliberately returns `Option`, not a default. A definition may come
311/// from untrusted markup, and `ChoiceOption.role` is author-assigned — so
312/// resolving an ambiguous answer by scanning the options for a `Deny` role
313/// would let whoever wrote the definition choose the failure mode. The caller
314/// supplies its own fail-closed constant; nothing here reads a role.
315#[must_use]
316pub fn resolve_typed(options: &[ChoiceOption], input: &str) -> Option<OptionId> {
317    let input = input.trim();
318    let single = |mut matches: std::vec::IntoIter<&ChoiceOption>| -> Option<OptionId> {
319        let first = matches.next()?;
320        // A second match is ambiguity, and ambiguity refuses.
321        matches.next().is_none().then(|| first.id.clone())
322    };
323
324    let canonical: Vec<&ChoiceOption> = options
325        .iter()
326        .filter(|o| o.id.as_str() == input || o.key == input)
327        .collect();
328    if !canonical.is_empty() {
329        return single(canonical.into_iter());
330    }
331    let aliased: Vec<&ChoiceOption> = options
332        .iter()
333        .filter(|o| o.aliases.iter().any(|alias| alias == input))
334        .collect();
335    single(aliased.into_iter())
336}
337
338fn check_value(control: &Control, value: &ControlValue) -> Result<Option<OptionId>, Refusal> {
339    match (&control.kind, value) {
340        (ControlKind::Choice { options }, ControlValue::Choice { option }) => {
341            if options.iter().any(|o| &o.id == option) {
342                Ok(Some(option.clone()))
343            } else {
344                Err(Refusal::UnknownOption {
345                    control: control.id.as_str().to_string(),
346                    option: option.as_str().to_string(),
347                })
348            }
349        }
350        (ControlKind::Toggle, ControlValue::Toggle { .. })
351        | (ControlKind::Secret, ControlValue::Secret { .. }) => Ok(None),
352        // **The value-shaped kinds all travel as text, and are checked here.**
353        //
354        // One variant on the wire rather than one per kind, for the same
355        // reason HTML gives every input a string value: a `ControlValue` per
356        // kind would put the definition's meaning in two places and let a
357        // response claim a shape the control never declared. What the control
358        // declares is what the answer is measured against — `check_text` is
359        // the single implementation, shared with every surface that wants to
360        // validate as you type.
361        (kind, ControlValue::Text { text }) if kind.travels_as_text() => kind
362            .check_text(text)
363            .map(|()| None)
364            .map_err(|expected| Refusal::MalformedValue {
365                control: control.id.as_str().to_string(),
366                expected,
367                found: text.clone(),
368            }),
369        _ => Err(Refusal::WrongControlType {
370            control: control.id.as_str().to_string(),
371            expected: kind_name(&control.kind),
372            found: value_name(value),
373        }),
374    }
375}
376
377/// Whether the responder presented an assertion that satisfies the offer.
378fn assertion_satisfied(response: &Response) -> bool {
379    match response.responder_provenance.kind {
380        AssertionKind::Unauthenticated => false,
381        AssertionKind::TerminalOperator | AssertionKind::SignedAssertion => {
382            response.responder_provenance.assertion.is_some()
383        }
384    }
385}
386
387/// Validate `response` against the offer it claims to answer.
388///
389/// Every rule is a refusal, and the whole `Vec<Submission>` stands or
390/// falls together: this returns [`Accepted`] with every action resolved,
391/// or a [`Refusal`] and nothing at all.
392///
393/// # Errors
394///
395/// A [`Refusal`] naming the first rule the response broke. No partial
396/// result is produced for a response that breaks any rule.
397pub fn validate_response(
398    definition: &InteractionDefinition,
399    instance: &InteractionInstance,
400    lifecycle: &Lifecycle,
401    response: &Response,
402    context: &ResponderContext<'_>,
403) -> Result<Accepted, Refusal> {
404    // 1. The offer must be open. An expired, cancelled, answered, or
405    //    unsupported offer refuses EVERY response, valid or not.
406    if lifecycle.state() != LifecycleState::Published {
407        return Err(Refusal::NotPublished {
408            state: lifecycle.state(),
409        });
410    }
411
412    // 2. The response must answer THIS offer.
413    let instance_id = instance.instance_id().map_err(Refusal::Protocol)?;
414    if response.instance != instance_id {
415        return Err(Refusal::InstanceMismatch {
416            named: response.instance.to_string(),
417            actual: instance_id.to_string(),
418        });
419    }
420    if lifecycle.instance() != &instance_id {
421        return Err(Refusal::InstanceMismatch {
422            named: lifecycle.instance().to_string(),
423            actual: instance_id.to_string(),
424        });
425    }
426
427    // 3. ...and the definition that offer binds...
428    if response.definition != instance.definition {
429        return Err(Refusal::DefinitionMismatch {
430            named: response.definition.to_string(),
431            offered: instance.definition.to_string(),
432        });
433    }
434
435    // 4. ...in the exact form it was minted against. A definition that
436    //    changed by one byte has a different id, so a response minted
437    //    against the old one cannot answer the new one.
438    let presented = definition.definition_id().map_err(Refusal::Protocol)?;
439    if response.definition != presented {
440        return Err(Refusal::DigestMismatch {
441            bound: response.definition.to_string(),
442            presented: presented.to_string(),
443        });
444    }
445
446    // 5. ...at the revision the responder actually saw.
447    if response.revision != definition.revision {
448        return Err(Refusal::StaleRevision {
449            offered: response.revision.get(),
450            current: definition.revision.get(),
451        });
452    }
453
454    // 6. The workspace fence.
455    if instance.scope.workspace_key != context.workspace_key {
456        return Err(Refusal::WorkspaceMismatch {
457            offered: instance.scope.workspace_key.clone(),
458            caller: context.workspace_key.to_string(),
459        });
460    }
461
462    // 7. Responder policy: audience, then assertion.
463    let audience = response.responder_provenance.audience.clone();
464    if !instance.responder_policy.audiences.contains(&audience) {
465        return Err(Refusal::AudienceMismatch { audience });
466    }
467    if instance.responder_policy.requires_assertion && !assertion_satisfied(response) {
468        return Err(Refusal::AssertionRequired);
469    }
470
471    // 8. The control set, exactly: no extras, no duplicates, every
472    //    required control answered, every value of the declared type.
473    let mut answered: Vec<&ControlId> = Vec::new();
474    let mut actions = Vec::new();
475    for submission in &response.values {
476        let Some(control) = definition
477            .controls
478            .iter()
479            .find(|c| c.id == submission.control)
480        else {
481            return Err(Refusal::ExtraControl {
482                control: submission.control.as_str().to_string(),
483            });
484        };
485        if answered.contains(&&submission.control) {
486            return Err(Refusal::DuplicateControl {
487                control: submission.control.as_str().to_string(),
488            });
489        }
490        answered.push(&submission.control);
491
492        if let Some(option) = check_value(control, &submission.value)? {
493            // 9. An action routes ONLY to a handler the caller
494            //    registered, and only for an eligible audience.
495            let Some(registration) = context.registered.iter().find(|r| r.option == option) else {
496                return Err(Refusal::UnknownAction {
497                    option: option.as_str().to_string(),
498                });
499            };
500            if !registration.audiences.contains(&audience) {
501                return Err(Refusal::ActionNotEligible {
502                    option: option.as_str().to_string(),
503                    audience,
504                });
505            }
506            actions.push(ResolvedAction {
507                control: control.id.clone(),
508                option,
509                handler: registration.handler.clone(),
510            });
511        }
512    }
513
514    for control in &definition.controls {
515        if control.requirement == Requirement::Required && !answered.contains(&&control.id) {
516            return Err(Refusal::MissingRequiredControl {
517                control: control.id.as_str().to_string(),
518            });
519        }
520    }
521
522    Ok(Accepted {
523        response: response.response_id().map_err(Refusal::Protocol)?,
524        instance: instance_id,
525        actions,
526    })
527}
528
529#[cfg(test)]
530mod model_fidelity {
531    use super::*;
532
533    /// **A third `Audience` variant would silently invalidate the formal
534    /// model, so it must not compile silently.**
535    ///
536    /// `PromptControls.tla` models two racers and a BINARY `SingleWinner`;
537    /// `Audience` is currently exactly `{Terminal, Web}`. It is
538    /// `#[non_exhaustive]`, so no downstream crate can write an
539    /// exhaustive match over it and no downstream test can notice a third
540    /// variant. This crate DEFINES it, so the match below — deliberately
541    /// without a `_` arm — is the one place that can: adding a variant
542    /// breaks compilation here, which is the prompt to revisit the model
543    /// rather than discover the gap later.
544    #[test]
545    fn the_modelled_audience_set_is_exactly_the_two_the_tla_covers() {
546        let modelled = [Audience::Terminal, Audience::Web];
547        for audience in &modelled {
548            match audience {
549                Audience::Terminal | Audience::Web => {}
550            }
551        }
552        assert_eq!(
553            modelled.len(),
554            2,
555            "PromptControls.tla's SingleWinner is binary; a third audience \
556             needs the model revisited, not just this number bumped"
557        );
558    }
559}
560
561#[cfg(test)]
562mod resolve_typed_tests {
563    use super::resolve_typed;
564    use crate::definition::{ChoiceOption, SemanticRole};
565    use crate::ids::OptionId;
566
567    /// **These tests MOVED here with the rule they govern (D0, #1878).**
568    ///
569    /// They lived beside `newt_core::tty::Question::parse`, which was the one
570    /// implementation of canonical-first / alias / ambiguity-denial until D0
571    /// moved it into `resolve_typed` so a second decoder would not have to be
572    /// written against `InteractionDefinition`. The coverage follows the code;
573    /// deleting it and re-deriving it later is how a rule loses its tests.
574    ///
575    /// `BHV-PROMPT-001`'s and `BHV-PROMPT-005`'s refs were repointed in the
576    /// same change.
577    fn option(id: &str, role: SemanticRole, key: &str, aliases: &[&str]) -> ChoiceOption {
578        ChoiceOption {
579            id: OptionId::new(id).expect("valid id"),
580            role,
581            label: id.to_string(),
582            key: key.to_string(),
583            aliases: aliases.iter().map(|a| (*a).to_string()).collect(),
584        }
585    }
586
587    /// The general permission menu: case-distinct `a`/`A` and `d`/`D`.
588    fn menu() -> Vec<ChoiceOption> {
589        vec![
590            option("allow_once", SemanticRole::Allow, "a", &[]),
591            option("allow_permanent", SemanticRole::Allow, "A", &[]),
592            option("deny", SemanticRole::Deny, "d", &[]),
593            option("deny_always", SemanticRole::Deny, "D", &[]),
594        ]
595    }
596
597    /// The mutation-confirm form: y/Y confirm, n/N deny, via hidden aliases.
598    fn confirm() -> Vec<ChoiceOption> {
599        vec![
600            option("allow_once", SemanticRole::Allow, "y", &["Y"]),
601            option("deny", SemanticRole::Deny, "n", &["N"]),
602        ]
603    }
604
605    fn resolved(options: &[ChoiceOption], input: &str) -> Option<String> {
606        resolve_typed(options, input).map(|id| id.as_str().to_string())
607    }
608
609    #[test]
610    fn lowercase_y_confirms() {
611        assert_eq!(resolved(&confirm(), "y").as_deref(), Some("allow_once"));
612    }
613
614    #[test]
615    fn uppercase_y_confirms_via_alias() {
616        assert_eq!(resolved(&confirm(), "Y").as_deref(), Some("allow_once"));
617    }
618
619    #[test]
620    fn lower_and_upper_n_deny() {
621        assert_eq!(resolved(&confirm(), "n").as_deref(), Some("deny"));
622        assert_eq!(resolved(&confirm(), "N").as_deref(), Some("deny"));
623    }
624
625    #[test]
626    fn empty_unknown_and_malformed_input_resolve_to_nothing() {
627        for bad in ["", "q", "yes", "y n", "\u{1b}", "yy"] {
628            assert_eq!(
629                resolved(&confirm(), bad),
630                None,
631                "input {bad:?} must not resolve"
632            );
633        }
634    }
635
636    #[test]
637    fn surrounding_whitespace_is_trimmed() {
638        assert_eq!(resolved(&confirm(), "  Y  ").as_deref(), Some("allow_once"));
639        assert_eq!(resolved(&confirm(), "\tn\n").as_deref(), Some("deny"));
640    }
641
642    /// Aliases must NOT fold case on the general menu: folding would let a
643    /// weaker answer select a stronger grant.
644    #[test]
645    fn general_menu_parsing_stays_case_sensitive() {
646        assert_eq!(resolved(&menu(), "a").as_deref(), Some("allow_once"));
647        assert_eq!(resolved(&menu(), "A").as_deref(), Some("allow_permanent"));
648        assert_eq!(resolved(&menu(), "d").as_deref(), Some("deny"));
649        assert_eq!(resolved(&menu(), "D").as_deref(), Some("deny_always"));
650    }
651
652    /// The stable wire name resolves as well as the accelerator.
653    #[test]
654    fn value_wire_name_still_matches() {
655        assert_eq!(
656            resolved(&menu(), "allow_permanent").as_deref(),
657            Some("allow_permanent")
658        );
659    }
660
661    /// An alias cannot select an option the form does not offer.
662    #[test]
663    fn an_alias_cannot_select_an_action_absent_from_the_question() {
664        let deny_only = vec![option("deny", SemanticRole::Deny, "n", &["N"])];
665        assert_eq!(resolved(&deny_only, "Y"), None);
666        assert_eq!(resolved(&deny_only, "y"), None);
667    }
668
669    /// **Ambiguity refuses, at BOTH tiers — never "first match wins".**
670    #[test]
671    fn ambiguity_refuses_rather_than_guessing() {
672        // Two options claiming the same canonical key.
673        let dup_key = vec![
674            option("allow_once", SemanticRole::Allow, "x", &[]),
675            option("deny", SemanticRole::Deny, "x", &[]),
676        ];
677        assert_eq!(resolved(&dup_key, "x"), None, "ambiguous key must refuse");
678
679        // Two options claiming the same alias.
680        let dup_alias = vec![
681            option("allow_once", SemanticRole::Allow, "y", &["z"]),
682            option("deny", SemanticRole::Deny, "n", &["z"]),
683        ];
684        assert_eq!(
685            resolved(&dup_alias, "z"),
686            None,
687            "ambiguous alias must refuse"
688        );
689        // …and each is still individually resolvable, or the above passes
690        // because nothing resolves at all.
691        assert_eq!(resolved(&dup_alias, "y").as_deref(), Some("allow_once"));
692        assert_eq!(resolved(&dup_alias, "n").as_deref(), Some("deny"));
693    }
694
695    /// A canonical match wins over an alias, so an alias can never shadow
696    /// another option's real key or wire name.
697    #[test]
698    fn a_canonical_match_beats_an_alias() {
699        let shadowing = vec![
700            option("allow_once", SemanticRole::Allow, "a", &[]),
701            // `deny` claims "a" as an ALIAS — it must not win.
702            option("deny", SemanticRole::Deny, "d", &["a"]),
703        ];
704        assert_eq!(resolved(&shadowing, "a").as_deref(), Some("allow_once"));
705    }
706
707    /// An alias must not shadow another option's WIRE NAME either — canonical
708    /// covers both the key and the id, and it is checked first.
709    #[test]
710    fn an_alias_never_shadows_another_options_wire_name() {
711        let shadowing = vec![
712            option("deny", SemanticRole::Deny, "d", &[]),
713            // `allow_once` claims "deny" — the other option's wire name — as
714            // an alias. Canonical wins.
715            option("allow_once", SemanticRole::Allow, "a", &["deny"]),
716        ];
717        assert_eq!(resolved(&shadowing, "deny").as_deref(), Some("deny"));
718    }
719
720    /// A KEY on one option colliding with the WIRE NAME of another is
721    /// ambiguous at the canonical tier, and refuses.
722    #[test]
723    fn key_value_collision_between_options_fails_closed() {
724        let collide = vec![
725            // this option's KEY is the other's wire name
726            option("allow_once", SemanticRole::Allow, "deny", &[]),
727            option("deny", SemanticRole::Deny, "d", &[]),
728        ];
729        assert_eq!(
730            resolved(&collide, "deny"),
731            None,
732            "a key-vs-wire-name collision is ambiguous and must refuse"
733        );
734    }
735
736    /// **Refusal is `None`, and nothing here reads a role to decide what
737    /// refusal means.** A definition can come from untrusted markup and
738    /// `role` is author-assigned, so a resolver that fell back to the `Deny`
739    /// option would let the author choose the failure mode (A3). Both these
740    /// forms refuse identically despite opposite role layouts.
741    #[test]
742    fn refusal_does_not_consult_the_authored_role() {
743        let deny_first = vec![
744            option("deny", SemanticRole::Deny, "d", &[]),
745            option("allow_once", SemanticRole::Allow, "a", &[]),
746        ];
747        let allow_first = vec![
748            option("allow_once", SemanticRole::Allow, "a", &[]),
749            option("deny", SemanticRole::Deny, "d", &[]),
750        ];
751        // An author who marks EVERYTHING Allow gets no say either.
752        let all_allow = vec![
753            option("allow_once", SemanticRole::Allow, "a", &[]),
754            option("deny", SemanticRole::Allow, "d", &[]),
755        ];
756        for options in [&deny_first, &allow_first, &all_allow] {
757            assert_eq!(
758                resolved(options, "nonsense"),
759                None,
760                "an unresolvable answer yields NOTHING, not a role-chosen option"
761            );
762        }
763    }
764}