Skip to main content

rustev_contract/
explanation.rs

1//! Decision explanation wire vocabulary and structural checks (spec 040, section 3).
2//! This module describes evidence; it computes no explanation or authorization.
3
4use crate::{
5    Document, DocumentError,
6    bounded::{ParseError, ParseLimits, parse_bounded},
7    ids::{ContentDigest, PlanId, RedactionPolicyId},
8    judgment::Derivation,
9    limits::REPLAY_V1,
10    redaction::FieldClass,
11    replay::CaptureStatus,
12    schema,
13};
14use serde::{Deserialize, Deserializer, Serialize};
15use serde_json::Value;
16
17pub const EXPLAINER: &str = "rustev-explain/1";
18pub const MAX_FACTS: usize = 10_000;
19
20// Explicit null is required for nullable members, rather than silently accepting omission.
21fn required<'de, D: Deserializer<'de>, T: Deserialize<'de>>(d: D) -> Result<T, D::Error> {
22    T::deserialize(d)
23}
24macro_rules! record {
25    ($name:ident { $($field:ident : $ty:ty),* $(,)? }) => {
26        #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
27        #[serde(deny_unknown_fields)]
28        pub struct $name { $(#[serde(deserialize_with = "required")] pub $field: $ty),* }
29    };
30}
31macro_rules! vocabulary {
32    ($name:ident, $case:literal, $($variant:ident),+ $(,)?) => {
33        #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, schemars::JsonSchema)]
34        #[serde(rename_all = $case)]
35        pub enum $name { $($variant),+ }
36        impl<'de> Deserialize<'de> for $name {
37            fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
38                #[derive(Deserialize)]
39                #[serde(rename_all = $case)]
40                // This helper mirrors the public, spec-defined wire vocabulary.
41                #[allow(clippy::enum_variant_names)]
42                enum Wire { $($variant),+ }
43                let name = String::deserialize(d)?;
44                let wire = Wire::deserialize(serde::de::value::StringDeserializer::<D::Error>::new(name))?;
45                Ok(match wire { $(Wire::$variant => Self::$variant),+ })
46            }
47        }
48    };
49}
50vocabulary!(SideName, "snake_case", A, B);
51// The availabilities of an item that is not read (3.1.3): spec 004, 3.1.7's
52// unreadable states, with a `digest_only` item named as such rather than
53// `missing`. `corrupt` and `mismatched` refuse the evidence set instead.
54vocabulary!(
55    ItemAvailability,
56    "snake_case",
57    Missing,
58    Expired,
59    Erased,
60    Inaccessible,
61    DigestOnly
62);
63vocabulary!(EvidenceForm, "snake_case", Record, Bundle);
64vocabulary!(WithholdingAction, "snake_case", Drop, Digest);
65vocabulary!(DerivationBound, "snake_case", Exact, Judgment);
66vocabulary!(LineageBound, "snake_case", Exact, Judgment, Plan);
67vocabulary!(
68    Facet,
69    "snake_case",
70    Tier,
71    Relaxation,
72    Presence,
73    AddressedTo,
74    Remediation,
75    CausedBy,
76    FieldOrigin,
77    Origin,
78    ServedIdentity
79);
80vocabulary!(
81    Claim,
82    "snake_case",
83    Membership,
84    RankPosition,
85    Score,
86    Components,
87    Weights,
88    Operator,
89    ShortlistPosition,
90    OrderingKey,
91    FiltersPassed,
92    ProposingRule,
93    Adjustments,
94    JudgmentDerivation,
95    JudgmentLineage,
96    Exclusion,
97    RuleDeclaration,
98    CutByK,
99    Pool,
100    PositionA,
101    PositionB,
102    ScoreA,
103    ScoreB,
104    ComponentsA,
105    ComponentsB,
106    OrderBy,
107    Consistent,
108    Outcome,
109    Matched,
110    Notices,
111    Termination,
112    Cause,
113    TextField,
114    Request,
115    Attempt,
116    Transitions,
117    CostSummary,
118    BackendCost,
119    Record,
120    Difference,
121    ObservationDifference,
122    OutputNotCompared,
123    Tier,
124    Relaxation,
125    Presence,
126    AddressedTo,
127    Remediation,
128    CausedBy,
129    FieldOrigin,
130    Origin,
131    ServedIdentity
132);
133vocabulary!(
134    RefusalCode,
135    "kebab-case",
136    PlanMismatch,
137    PlanAmbiguous,
138    ScopeMismatch,
139    EvidenceIntegrity,
140    InvalidQuery,
141    InvalidOptions,
142    ExplanationTooLarge,
143    Parse
144);
145impl RefusalCode {
146    pub const fn as_str(self) -> &'static str {
147        match self {
148            Self::PlanMismatch => "plan-mismatch",
149            Self::PlanAmbiguous => "plan-ambiguous",
150            Self::ScopeMismatch => "scope-mismatch",
151            Self::EvidenceIntegrity => "evidence-integrity",
152            Self::InvalidQuery => "invalid-query",
153            Self::InvalidOptions => "invalid-options",
154            Self::ExplanationTooLarge => "explanation-too-large",
155            Self::Parse => "parse",
156        }
157    }
158}
159
160/// A validated evidence path, ordered by the bytes of its complete wire string (3.2).
161#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, schemars::JsonSchema)]
162#[serde(transparent)]
163pub struct EvidencePath(String);
164impl EvidencePath {
165    pub fn parse(s: &str) -> Result<Self, DocumentError> {
166        let unprefixed = s
167            .strip_prefix("a.")
168            .or_else(|| s.strip_prefix("b."))
169            .unwrap_or(s);
170        let (role, pointer) = unprefixed
171            .split_once(':')
172            .ok_or_else(|| invalid("evidence path lacks colon"))?;
173        let supply = role.strip_prefix("supply/").is_some_and(|n| {
174            !n.is_empty()
175                && n.bytes().all(|b| b.is_ascii_digit())
176                && (n == "0" || !n.starts_with('0'))
177                && n.parse::<u64>().is_ok()
178        });
179        if !matches!(role, "record" | "plan" | "bundle" | "snapshot") && !supply {
180            return Err(invalid("invalid evidence role"));
181        }
182        if !pointer.is_empty() && !pointer.starts_with('/') {
183            return Err(invalid("invalid JSON pointer"));
184        }
185        let mut bytes = pointer.bytes();
186        while let Some(b) = bytes.next() {
187            if b == b'~' && !matches!(bytes.next(), Some(b'0' | b'1')) {
188                return Err(invalid("invalid JSON pointer escape"));
189            }
190        }
191        Ok(Self(s.into()))
192    }
193    pub fn as_str(&self) -> &str {
194        &self.0
195    }
196    pub fn side(&self) -> Option<SideName> {
197        if self.0.starts_with("a.") {
198            Some(SideName::A)
199        } else if self.0.starts_with("b.") {
200            Some(SideName::B)
201        } else {
202            None
203        }
204    }
205    pub fn resolve<'a>(&self, document: &'a Value) -> Option<&'a Value> {
206        document.pointer(self.0.split_once(':')?.1)
207    }
208}
209impl std::str::FromStr for EvidencePath {
210    type Err = DocumentError;
211    fn from_str(s: &str) -> Result<Self, Self::Err> {
212        Self::parse(s)
213    }
214}
215impl<'de> Deserialize<'de> for EvidencePath {
216    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
217        Self::parse(&String::deserialize(d)?).map_err(serde::de::Error::custom)
218    }
219}
220
221#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
222#[serde(tag = "reason", rename_all = "kebab-case", deny_unknown_fields)]
223pub enum Unavailable {
224    NotRecorded {
225        #[serde(default, skip_serializing_if = "Option::is_none")]
226        facet: Option<Facet>,
227    },
228    PlanNotSupplied {},
229    Retention {
230        item: EvidencePath,
231        availability: ItemAvailability,
232    },
233    NotProduced {},
234    Redacted {
235        policy: RedactionPolicyId,
236        field_class: FieldClass,
237        action: WithholdingAction,
238    },
239    NotInRecord {},
240}
241record!(Withheld {
242    not_available: Unavailable
243});
244#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
245#[serde(untagged)]
246pub enum Item {
247    String(String),
248    Withheld(Withheld),
249}
250record!(DigestValue {
251    digest: ContentDigest
252});
253// serde ignores `deny_unknown_fields` on an untagged enum, so each arm is a
254// struct that refuses unknown fields itself.
255#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
256#[serde(untagged)]
257pub enum DigestRef {
258    Digest(DigestValue),
259    Withheld(Withheld),
260}
261#[derive(Debug, Clone, PartialEq, Serialize, schemars::JsonSchema)]
262#[serde(rename_all = "snake_case", deny_unknown_fields)]
263pub enum Availability {
264    Available,
265    NotAvailable(Unavailable),
266}
267impl<'de> Deserialize<'de> for Availability {
268    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
269        let value = Value::deserialize(d)?;
270        if value == Value::String("available".into()) {
271            return Ok(Self::Available);
272        }
273        let withheld: Withheld = serde_json::from_value(value).map_err(serde::de::Error::custom)?;
274        Ok(Self::NotAvailable(withheld.not_available))
275    }
276}
277record!(Side { name: SideName, form: EvidenceForm, record: DigestRef, plan: Option<PlanId>,
278    bundle: Option<ContentDigest>, capture: Option<CaptureStatus>, decision_id: Item, plan_id: Item });
279record!(FacetSource {
280    name: Facet,
281    spec: String,
282    path: String
283});
284record!(Redaction {
285    policy: RedactionPolicyId
286});
287record!(Truncation { total: u64 });
288record!(FactDerivation {
289    class: Derivation,
290    bound: DerivationBound
291});
292record!(FactLineage { inputs: Vec<String>, steps: Vec<String>, bound: LineageBound });
293record!(Truncated {
294    policy: RedactionPolicyId,
295    bytes: u64
296});
297record!(Fact { id: String, claim: Claim, value: Value, availability: Availability,
298    derivation: Option<FactDerivation>, lineage: Option<FactLineage>,
299    truncated: Option<Truncated>, cites: Vec<EvidencePath> });
300
301#[derive(Debug, Clone, PartialEq, Serialize, schemars::JsonSchema)]
302#[serde(rename_all = "snake_case", deny_unknown_fields)]
303pub enum Query {
304    WhySelected { item: Item },
305    WhyExcluded { item: Item },
306    WhyOrder { a: Item, b: Item },
307    WhyOutcome,
308    TextOrigin,
309    Models,
310    Diff,
311}
312#[derive(Deserialize)]
313#[serde(rename_all = "snake_case", deny_unknown_fields)]
314enum ItemQuery {
315    #[serde(rename = "why_selected")]
316    Selected { item: Item },
317    #[serde(rename = "why_excluded")]
318    Excluded { item: Item },
319    #[serde(rename = "why_order")]
320    Order { a: Item, b: Item },
321}
322impl<'de> Deserialize<'de> for Query {
323    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
324        let value = Value::deserialize(d)?;
325        if let Value::String(name) = &value {
326            return match name.as_str() {
327                "why_outcome" => Ok(Self::WhyOutcome),
328                "text_origin" => Ok(Self::TextOrigin),
329                "models" => Ok(Self::Models),
330                "diff" => Ok(Self::Diff),
331                _ => Err(serde::de::Error::custom("unknown query")),
332            };
333        }
334        match serde_json::from_value::<ItemQuery>(value).map_err(serde::de::Error::custom)? {
335            ItemQuery::Selected { item } => Ok(Self::WhySelected { item }),
336            ItemQuery::Excluded { item } => Ok(Self::WhyExcluded { item }),
337            ItemQuery::Order { a, b } => Ok(Self::WhyOrder { a, b }),
338        }
339    }
340}
341impl Query {
342    pub fn validate(&self) -> Result<(), RefusalCode> {
343        let valid = |item: &Item| match item {
344            Item::String(s) => (1..=256).contains(&s.len()),
345            Item::Withheld(w) => matches!(w.not_available, Unavailable::Redacted { .. }),
346        };
347        let ok = match self {
348            Self::WhySelected { item } | Self::WhyExcluded { item } => valid(item),
349            // Equal withheld placeholders say nothing about the original item identities.
350            Self::WhyOrder { a, b } => {
351                valid(a)
352                    && valid(b)
353                    && !matches!((a, b), (Item::String(a), Item::String(b)) if a == b)
354            }
355            _ => true,
356        };
357        if ok {
358            Ok(())
359        } else {
360            Err(RefusalCode::InvalidQuery)
361        }
362    }
363}
364
365// Fact references have their own type so answer validation cannot confuse an item
366// or parameter named "f1" with a reference to that fact.
367#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
368#[serde(transparent)]
369pub struct FactId(pub String);
370vocabulary!(ValueKind, "snake_case", Filtered, Shortlist, Ranking);
371vocabulary!(SeeExcluded, "snake_case", WhyExcluded);
372vocabulary!(SeeSelected, "snake_case", WhySelected);
373vocabulary!(OrderBy, "snake_case", Score, TieBreak, Position);
374vocabulary!(ComparisonBy, "snake_case", Score, TieBreak);
375vocabulary!(
376    Divergence,
377    "kebab-case",
378    PlanChanged,
379    SnapshotChanged,
380    EvaluationTimeChanged,
381    InputChanged,
382    BackendOutputChanged,
383    BackendChanged,
384    RequestResultChanged,
385    StepChanged,
386    OutcomeChanged
387);
388record!(SelectedScope { param: String, value_kind: ValueKind, facts: Vec<FactId> });
389record!(ExcludedScope {
390    param: String,
391    value_kind: ValueKind,
392    exclusion: FactId,
393    rule_declaration: FactId
394});
395record!(Cut {
396    param: String,
397    cut_by_k: FactId
398});
399record!(OrderScope { param: String, first: Option<Item>, by: OrderBy, consistent: Option<bool>,
400    facts: Vec<FactId> });
401// A disagreeing scope alone carries disagrees_with; untagged parsing refuses every extra field.
402record!(DisagreeingScope { param: String, first: Option<Item>, by: OrderBy, consistent: bool,
403    disagrees_with: ComparisonBy, facts: Vec<FactId> });
404#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
405#[serde(untagged)]
406pub enum OrderingScope {
407    Disagrees(DisagreeingScope),
408    Other(OrderScope),
409}
410record!(TextField {
411    text_field: FactId,
412    field_origin: FactId
413});
414record!(ModelRequest { request: FactId, attempts: Vec<FactId>, transitions: FactId });
415record!(ModelBackend { backend: Item, attempts: Vec<FactId>, served_identity: FactId, backend_cost: FactId });
416
417macro_rules! answer {
418    ($name:ident { $($body:tt)* }) => {
419        #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
420        #[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
421        pub enum $name { $($body)* }
422    };
423}
424answer!(SelectedAnswer {
425    NotAvailable { outcome: FactId }, NoProposal { outcome: FactId },
426    NotSelected { see: SeeExcluded },
427    Selected { scopes: Vec<SelectedScope>, proposing_rule: FactId, adjustments: FactId,
428        judgment_derivation: FactId, judgment_lineage: FactId, facets: Vec<FactId> },
429});
430answer!(ExcludedAnswer {
431    NotAvailable { outcome: FactId }, NoProposal { outcome: FactId },
432    Excluded { scopes: Vec<ExcludedScope>, cuts: Vec<Cut>, selected_in: Vec<String>, facets: Vec<FactId> },
433    Cut { cuts: Vec<Cut>, selected_in: Vec<String>, facets: Vec<FactId> },
434    Selected { see: SeeSelected }, NotInPool { pool: FactId, facets: Vec<FactId> },
435    Unknown { pool: FactId, facets: Vec<FactId> },
436});
437#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
438#[serde(tag = "reason", rename_all = "kebab-case", deny_unknown_fields)]
439pub enum Incomparable {
440    DifferentScope {},
441    NotRanked { items: Vec<Item> },
442}
443answer!(OrderAnswer {
444    NotAvailable { outcome: FactId }, Ordered { scopes: Vec<OrderingScope> },
445    Incomparable(Incomparable),
446});
447record!(OutcomeFacts { outcome: FactId, matched: FactId, adjustments: FactId, notices: FactId,
448    judgment_derivation: FactId, judgment_lineage: FactId, termination: FactId,
449    causes: Vec<FactId>, facets: Vec<FactId> });
450answer!(OutcomeAnswer {
451    NotAvailable { outcome: FactId }, Proposed(OutcomeFacts), Escalated(OutcomeFacts), Unresolved(OutcomeFacts),
452});
453answer!(TextAnswer { NotAvailable { outcome: FactId }, TextFields { fields: Vec<TextField> }, NoText {} });
454answer!(ModelsAnswer {
455    NotAvailable { record: FactId }, NoModelCalls { cost_summary: FactId },
456    Models { requests: Vec<ModelRequest>, backends: Vec<ModelBackend>, cost_summary: FactId },
457});
458answer!(DiffAnswer {
459    Identical { observations: Vec<FactId>, not_compared: Vec<FactId> },
460    Diverged { first: FactId, why: Divergence, differences: Vec<FactId>, total: u64,
461        truncated: u64, observations: Vec<FactId>, not_compared: Vec<FactId> },
462    Withheld { redacted: Unavailable },
463});
464
465#[derive(Debug, Clone, PartialEq, Serialize, schemars::JsonSchema)]
466#[serde(untagged)]
467pub enum Answer {
468    WhySelected(SelectedAnswer),
469    WhyExcluded(ExcludedAnswer),
470    WhyOrder(OrderAnswer),
471    WhyOutcome(OutcomeAnswer),
472    TextOrigin(TextAnswer),
473    Models(ModelsAnswer),
474    Diff(DiffAnswer),
475}
476impl Answer {
477    fn parse(query: &Query, value: Value) -> Result<Self, serde_json::Error> {
478        Ok(match query {
479            Query::WhySelected { .. } => Self::WhySelected(serde_json::from_value(value)?),
480            Query::WhyExcluded { .. } => Self::WhyExcluded(serde_json::from_value(value)?),
481            Query::WhyOrder { .. } => Self::WhyOrder(serde_json::from_value(value)?),
482            Query::WhyOutcome => Self::WhyOutcome(serde_json::from_value(value)?),
483            Query::TextOrigin => Self::TextOrigin(serde_json::from_value(value)?),
484            Query::Models => Self::Models(serde_json::from_value(value)?),
485            Query::Diff => Self::Diff(serde_json::from_value(value)?),
486        })
487    }
488}
489
490/// The record canonical explanation document (spec 040, 3.3).
491#[derive(Debug, Clone, PartialEq, Serialize, schemars::JsonSchema)]
492#[serde(deny_unknown_fields)]
493pub struct Explanation {
494    pub schema: String,
495    pub explainer: String,
496    pub facet_sources: Vec<FacetSource>,
497    pub sides: Vec<Side>,
498    pub redaction: Option<Redaction>,
499    pub query: Query,
500    pub answer: Answer,
501    pub truncation: Option<Truncation>,
502    pub facts: Vec<Fact>,
503}
504record!(ExplanationWire { schema: String, explainer: String, facet_sources: Vec<FacetSource>,
505    sides: Vec<Side>, redaction: Option<Redaction>, query: Query, answer: Value,
506    truncation: Option<Truncation>, facts: Vec<Fact> });
507impl<'de> Deserialize<'de> for Explanation {
508    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
509        let w = ExplanationWire::deserialize(d)?;
510        let answer = Answer::parse(&w.query, w.answer).map_err(serde::de::Error::custom)?;
511        Ok(Self {
512            schema: w.schema,
513            explainer: w.explainer,
514            facet_sources: w.facet_sources,
515            sides: w.sides,
516            redaction: w.redaction,
517            query: w.query,
518            answer,
519            truncation: w.truncation,
520            facts: w.facts,
521        })
522    }
523}
524impl Document for Explanation {
525    const SCHEMA: &'static str = schema::EXPLANATION;
526    const LIMITS: ParseLimits = REPLAY_V1;
527    fn schema(&self) -> &str {
528        &self.schema
529    }
530    fn parse(bytes: &[u8]) -> Result<Self, DocumentError> {
531        let doc: Self = parse_bounded(bytes, &Self::LIMITS).map_err(DocumentError::Parse)?;
532        doc.validate()?;
533        Ok(doc)
534    }
535}
536fn invalid(message: &str) -> DocumentError {
537    DocumentError::Parse(ParseError::Schema {
538        message: message.into(),
539    })
540}
541impl Explanation {
542    /// Checks structure only. Resolution and factual correctness belong to the explainer.
543    pub fn validate(&self) -> Result<(), DocumentError> {
544        if self.schema != schema::EXPLANATION {
545            return Err(DocumentError::Schema {
546                expected: schema::EXPLANATION,
547                found: self.schema.chars().take(80).collect(),
548            });
549        }
550        if self.explainer != EXPLAINER {
551            return Err(invalid("invalid explainer"));
552        }
553        self.query.validate().map_err(|e| invalid(e.as_str()))?;
554        let diff = matches!(self.query, Query::Diff);
555        let names: Vec<_> = self.sides.iter().map(|s| s.name).collect();
556        if names
557            != if diff {
558                vec![SideName::A, SideName::B]
559            } else {
560                vec![SideName::A]
561            }
562        {
563            return Err(invalid("invalid sides for query"));
564        }
565        for side in &self.sides {
566            if let Item::String(id) = &side.plan_id {
567                PlanId::parse(id).map_err(|e| invalid(&e.to_string()))?;
568            }
569        }
570        if self.facts.len() > MAX_FACTS {
571            return Err(invalid("explanation-too-large"));
572        }
573        for (i, fact) in self.facts.iter().enumerate() {
574            if fact.id != format!("f{}", i + 1) {
575                return Err(invalid("facts must be numbered f1..fn"));
576            }
577            if matches!(fact.availability, Availability::NotAvailable(_))
578                && (!fact.value.is_null() || fact.truncated.is_some())
579            {
580                return Err(invalid("unavailable fact has value or truncation"));
581            }
582            if !fact.cites.windows(2).all(|w| w[0] < w[1]) {
583                return Err(invalid("citations must be sorted and unique"));
584            }
585            if fact.cites.iter().any(|p| p.side().is_some() != diff) {
586                return Err(invalid("citation side does not match query"));
587            }
588        }
589        if !self.facet_sources.windows(2).all(|w| w[0].name < w[1].name) {
590            return Err(invalid("facet sources must be in reserved order"));
591        }
592        let value = serde_json::to_value(&self.answer).map_err(|e| invalid(&e.to_string()))?;
593        let reparsed =
594            Answer::parse(&self.query, value.clone()).map_err(|e| invalid(&e.to_string()))?;
595        if reparsed != self.answer {
596            return Err(invalid("answer does not belong to query"));
597        }
598        // Only these members are metadata, never references. All remaining answer
599        // leaves are FactIds, including nested scope, request and backend members.
600        fn references(v: &Value, count: usize) -> bool {
601            match v {
602                Value::String(id) => id
603                    .strip_prefix('f')
604                    .and_then(|s| s.parse::<usize>().ok())
605                    .is_some_and(|n| n > 0 && n <= count && *id == format!("f{n}")),
606                Value::Array(a) => a.iter().all(|v| references(v, count)),
607                Value::Object(o) => o.iter().all(|(k, v)| {
608                    matches!(
609                        k.as_str(),
610                        "kind"
611                            | "param"
612                            | "value_kind"
613                            | "see"
614                            | "selected_in"
615                            | "reason"
616                            | "items"
617                            | "backend"
618                            | "by"
619                            | "consistent"
620                            | "disagrees_with"
621                            | "why"
622                            | "total"
623                            | "truncated"
624                            | "redacted"
625                    ) || (k == "first" && o.contains_key("param"))
626                        || references(v, count)
627                }),
628                _ => false,
629            }
630        }
631        if !references(&value, self.facts.len()) {
632            return Err(invalid("answer names missing fact"));
633        }
634        if let Answer::WhyOrder(OrderAnswer::Ordered { scopes }) = &self.answer {
635            for scope in scopes {
636                let valid = match scope {
637                    OrderingScope::Disagrees(s) => s.by == OrderBy::Position && !s.consistent,
638                    OrderingScope::Other(s) => match s.by {
639                        OrderBy::Position => s.consistent.is_none(),
640                        OrderBy::Score | OrderBy::TieBreak => {
641                            s.consistent == Some(true) && s.first.is_some()
642                        }
643                    },
644                };
645                if !valid {
646                    return Err(invalid("invalid ordering scope combination"));
647                }
648            }
649        }
650        let bytes = self
651            .record_canonical()
652            .map_err(|e| invalid(&e.to_string()))?;
653        crate::bounded::scan(&bytes, &REPLAY_V1)
654            .map_err(|e| DocumentError::Parse(ParseError::Bound(e)))?;
655        Ok(())
656    }
657}