Skip to main content

supercov_engine/
assertion_map.rs

1//! Agent-authored assertion maps. Edges are explanations, never inferred proofs.
2//! This module owns format validation, text relocation and input acknowledgement bookkeeping.
3
4use serde::{Deserialize, Serialize};
5use sha2::{Digest, Sha256};
6use std::collections::{BTreeMap, BTreeSet};
7
8pub type Files = BTreeMap<String, String>;
9pub fn digest(value: &impl Serialize) -> String {
10    format!(
11        "{:x}",
12        Sha256::digest(serde_json::to_vec(value).expect("serializable map"))
13    )
14}
15fn version() -> u32 {
16    1
17}
18
19/// One-based lines and UTF-8 byte columns, for every language. Text is exact.
20#[derive(
21    Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, schemars::JsonSchema,
22)]
23#[serde(rename_all = "camelCase", deny_unknown_fields)]
24pub struct Anchor {
25    pub file: String,
26    pub line: usize,
27    pub column: usize,
28    pub text: String,
29}
30
31pub fn local_path(file: &str) -> bool {
32    !file.is_empty()
33        && !file.contains(['\\', ':'])
34        && file.split('/').all(|p| !matches!(p, "" | "." | ".."))
35}
36impl Anchor {
37    pub fn new(file: &str, source: &str, start: usize, end: usize) -> Self {
38        Self {
39            file: file.into(),
40            line: source[..start].bytes().filter(|b| *b == b'\n').count() + 1,
41            column: start - source[..start].rfind('\n').map_or(0, |n| n + 1) + 1,
42            text: source[start..end].into(),
43        }
44    }
45    pub fn offset(&self, files: &Files) -> Option<usize> {
46        if !local_path(&self.file) || self.text.is_empty() || self.line == 0 || self.column == 0 {
47            return None;
48        }
49        let source = files.get(&self.file)?;
50        let start = source
51            .split_inclusive('\n')
52            .take(self.line - 1)
53            .map(str::len)
54            .sum::<usize>();
55        if source[..start].bytes().filter(|b| *b == b'\n').count() != self.line - 1 {
56            return None;
57        }
58        let line = source.get(start..)?.split('\n').next()?;
59        if self.column - 1 > line.len() {
60            return None;
61        }
62        let pos = start.checked_add(self.column - 1)?;
63        source.get(pos..)?.starts_with(&self.text).then_some(pos)
64    }
65}
66
67#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
68#[serde(rename_all = "camelCase", deny_unknown_fields)]
69pub struct InventorySite {
70    pub at: Anchor,
71    pub operation: String,
72}
73
74/// Source text held in memory for capture or a verified current-checkout query.
75/// The serialized form is retained only for reading legacy source archives.
76#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
77#[serde(rename_all = "camelCase", deny_unknown_fields)]
78pub struct Inputs {
79    #[serde(default = "version")]
80    pub schema_version: u32,
81    pub language: String,
82    pub context_digest: String,
83    pub files: Files,
84    pub assertions: Vec<InventorySite>,
85    pub limitations: Vec<String>,
86}
87
88#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
89#[serde(rename_all = "camelCase", deny_unknown_fields)]
90pub struct FileFingerprint {
91    pub sha256: String,
92    pub bytes: usize,
93}
94impl FileFingerprint {
95    pub fn of(source: &str) -> Self {
96        Self {
97            sha256: format!("{:x}", Sha256::digest(source.as_bytes())),
98            bytes: source.len(),
99        }
100    }
101}
102pub type FileManifest = BTreeMap<String, FileFingerprint>;
103
104/// The run stores identities and hashes, never complete source files.
105#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
106#[serde(rename_all = "camelCase", deny_unknown_fields)]
107pub struct InputManifest {
108    pub schema_version: u32,
109    pub language: String,
110    pub context_digest: String,
111    pub files: FileManifest,
112    pub assertions: Vec<InventorySite>,
113    pub limitations: Vec<String>,
114}
115impl Inputs {
116    pub fn manifest(&self) -> InputManifest {
117        InputManifest {
118            schema_version: 2,
119            language: self.language.clone(),
120            context_digest: self.context_digest.clone(),
121            files: self
122                .files
123                .iter()
124                .map(|(p, s)| (p.clone(), FileFingerprint::of(s)))
125                .collect(),
126            assertions: self.assertions.clone(),
127            limitations: self.limitations.clone(),
128        }
129    }
130    pub fn identity(&self) -> String {
131        digest(&self.manifest())
132    }
133}
134impl InputManifest {
135    pub fn with_sources(&self, files: Files) -> Inputs {
136        Inputs {
137            schema_version: 1,
138            language: self.language.clone(),
139            context_digest: self.context_digest.clone(),
140            files,
141            assertions: self.assertions.clone(),
142            limitations: self.limitations.clone(),
143        }
144    }
145}
146
147#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
148#[serde(rename_all = "camelCase", deny_unknown_fields)]
149pub struct Node {
150    pub id: String,
151    pub at: Anchor,
152    #[serde(default, skip_serializing_if = "String::is_empty")]
153    pub role: String,
154    #[serde(default, skip_serializing_if = "String::is_empty")]
155    pub meaning: String,
156}
157#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
158#[serde(rename_all = "camelCase", deny_unknown_fields)]
159pub struct Edge {
160    pub from: String,
161    pub to: String,
162    pub kind: String,
163    #[serde(default, skip_serializing_if = "String::is_empty")]
164    pub basis: String,
165}
166#[derive(
167    Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, schemars::JsonSchema,
168)]
169#[serde(rename_all = "camelCase", deny_unknown_fields)]
170pub struct TestSelector {
171    pub file: String,
172    pub name: String,
173}
174
175#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
176#[serde(rename_all = "camelCase", deny_unknown_fields)]
177pub struct Flow {
178    pub id: String,
179    #[serde(deserialize_with = "required_basis")]
180    #[schemars(required, schema_with = "basis_schema")]
181    pub basis: Option<String>,
182    pub explanation: String,
183    pub applies_to: Vec<TestSelector>,
184    pub nodes: Vec<Node>,
185    #[serde(default)]
186    pub edges: Vec<Edge>,
187    pub counts_as_asserted: Vec<String>,
188    pub watch: Vec<String>,
189    #[serde(default, skip_serializing_if = "Vec::is_empty")]
190    pub questions: Vec<String>,
191}
192#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
193#[serde(rename_all = "camelCase", deny_unknown_fields)]
194pub struct Assertion {
195    pub id: String,
196    pub at: Anchor,
197    #[serde(default, skip_serializing_if = "Vec::is_empty")]
198    pub questions: Vec<String>,
199    #[serde(default)]
200    pub observes: Vec<String>,
201    #[serde(default)]
202    pub flows: Vec<Flow>,
203}
204#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
205#[serde(rename_all = "camelCase", deny_unknown_fields)]
206pub struct Retired {
207    pub assertion: Assertion,
208    pub reason: String,
209}
210#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
211#[serde(rename_all = "camelCase", deny_unknown_fields)]
212pub struct AssertionMap {
213    #[schemars(range(min = 2, max = 2))]
214    pub schema_version: u32,
215    pub assertions: Vec<Assertion>,
216    #[serde(default, skip_serializing_if = "Vec::is_empty")]
217    pub change_assessments: Vec<ChangeAssessment>,
218    #[serde(default, skip_serializing_if = "Vec::is_empty")]
219    pub retired_assertions: Vec<Retired>,
220}
221
222// Missing basis is a syntax error; null explicitly means unfinished work.
223fn required_basis<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Option<String>, D::Error> {
224    let value = Option::<String>::deserialize(d)?;
225    if value.as_deref().is_some_and(|s| !valid_basis(s)) {
226        return Err(serde::de::Error::custom(
227            "expected null or scov2:<64 lowercase hex digits>",
228        ));
229    }
230    Ok(value)
231}
232fn basis_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
233    schemars::json_schema!({"type":["string","null"],"pattern":"^scov2:[0-9a-f]{64}$"})
234}
235fn valid_basis(s: &str) -> bool {
236    s.strip_prefix("scov2:").is_some_and(|h| {
237        h.len() == 64
238            && h.bytes()
239                .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
240    })
241}
242#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
243#[serde(rename_all = "camelCase", deny_unknown_fields)]
244pub struct ChangeAssessment {
245    pub id: String,
246    #[serde(deserialize_with = "required_basis")]
247    #[schemars(required, schema_with = "basis_schema")]
248    pub basis: Option<String>,
249    pub affected_flows: Vec<String>,
250    pub explanation: String,
251}
252
253/// Editor schema generated from the same Rust types used by every map command.
254/// Source existence, links, freshness and semantic meaning are outside JSON Schema.
255pub fn schema() -> serde_json::Value {
256    serde_json::to_value(schemars::schema_for!(AssertionMap)).expect("schema")
257}
258
259#[derive(Debug, Serialize)]
260#[serde(rename_all = "camelCase")]
261pub struct ParseError {
262    pub pointer: String,
263    pub line: usize,
264    pub column: usize,
265    pub message: String,
266}
267impl std::fmt::Display for ParseError {
268    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
269        write!(
270            f,
271            "{} at {} (JSON line {}, column {})",
272            self.message, self.pointer, self.line, self.column
273        )
274    }
275}
276pub fn parse(bytes: &[u8]) -> Result<AssertionMap, ParseError> {
277    let mut deserializer = serde_json::Deserializer::from_slice(bytes);
278    let map: AssertionMap = serde_path_to_error::deserialize(&mut deserializer).map_err(|e| {
279        let pointer = e
280            .path()
281            .iter()
282            .map(|segment| {
283                use serde_path_to_error::Segment;
284                let part = match segment {
285                    Segment::Seq { index } => index.to_string(),
286                    Segment::Map { key } => key.clone(),
287                    Segment::Enum { variant } => variant.clone(),
288                    Segment::Unknown => "?".into(),
289                };
290                format!("/{}", part.replace('~', "~0").replace('/', "~1"))
291            })
292            .collect();
293        ParseError {
294            pointer,
295            line: e.inner().line(),
296            column: e.inner().column(),
297            message: e.inner().to_string(),
298        }
299    })?;
300    deserializer.end().map_err(|e| ParseError {
301        pointer: String::new(),
302        line: e.line(),
303        column: e.column(),
304        message: e.to_string(),
305    })?;
306    if map.schema_version != 2 {
307        return Err(ParseError {
308            pointer: "/schemaVersion".into(),
309            line: 0,
310            column: 0,
311            message: "unsupported map schema version; expected 2".into(),
312        });
313    }
314    Ok(map)
315}
316#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
317#[serde(rename_all = "camelCase", deny_unknown_fields)]
318pub struct FlowState {
319    pub generation: String,
320    pub reasons: BTreeSet<String>,
321}
322#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
323#[serde(rename_all = "camelCase", deny_unknown_fields)]
324pub struct Change {
325    pub id: String,
326    pub file: Option<String>,
327    pub before: Option<String>,
328    pub after: Option<String>,
329    pub reason: String,
330    pub known_flows: BTreeSet<String>,
331}
332#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
333#[serde(rename_all = "camelCase", deny_unknown_fields)]
334pub struct State {
335    pub schema_version: u32,
336    pub inputs_digest: String,
337    pub evidence_digest: String,
338    pub flows: BTreeMap<String, FlowState>,
339    pub changes: Vec<Change>,
340    #[serde(default, skip_serializing_if = "Option::is_none")]
341    pub inheritance: Option<Inheritance>,
342}
343#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
344#[serde(rename_all = "camelCase", deny_unknown_fields)]
345pub struct Inheritance {
346    pub from: Option<String>,
347    pub skipped: Vec<SkippedMap>,
348}
349#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
350#[serde(rename_all = "camelCase", deny_unknown_fields)]
351pub struct SkippedMap {
352    pub run: String,
353    pub reason: String,
354}
355pub fn flow_key(a: &Assertion, f: &Flow) -> String {
356    format!("{}/{}", a.id, f.id)
357}
358fn valid_id(id: &str) -> bool {
359    !id.is_empty()
360        && id
361            .bytes()
362            .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'.'))
363}
364
365pub fn seed(inputs: &Inputs, evidence_digest: &str) -> (AssertionMap, State) {
366    seed_manifest(&inputs.manifest(), evidence_digest)
367}
368pub fn seed_manifest(inputs: &InputManifest, evidence_digest: &str) -> (AssertionMap, State) {
369    (
370        AssertionMap {
371            schema_version: 2,
372            assertions: inputs
373                .assertions
374                .iter()
375                .map(|site| Assertion {
376                    id: format!("a_{}", &digest(&site.at)[..20]),
377                    at: site.at.clone(),
378                    questions: vec![],
379                    observes: vec![],
380                    flows: vec![],
381                })
382                .collect(),
383            change_assessments: vec![],
384            retired_assertions: vec![],
385        },
386        State {
387            schema_version: 3,
388            inputs_digest: digest(inputs),
389            evidence_digest: evidence_digest.into(),
390            flows: BTreeMap::new(),
391            changes: vec![],
392            inheritance: None,
393        },
394    )
395}
396
397/// Structural checks only; malformed entries cannot silently earn credit.
398pub fn validate(map: &AssertionMap, inputs: &Inputs) -> Vec<String> {
399    let mut errors = Vec::new();
400    if map.schema_version != 2 || inputs.schema_version != 1 {
401        errors.push("unsupported schema version".into());
402    }
403    let mut ids = BTreeSet::new();
404    let mut sites = BTreeSet::new();
405    for a in &map.assertions {
406        if !valid_id(&a.id) || !ids.insert(&a.id) {
407            errors.push(format!("{}: invalid/duplicate assertion ID", a.id));
408        }
409        if !sites.insert(&a.at) {
410            errors.push(format!("{}: duplicate assertion location", a.id));
411        }
412        if a.at.offset(&inputs.files).is_none() {
413            errors.push(format!("{}: invalid assertion anchor", a.id));
414        }
415        // Agents can register custom assertions absent from the syntax inventory.
416        // They still require exact run evidence to earn execution-backed credit.
417        let mut flows = BTreeSet::new();
418        for f in &a.flows {
419            let key = flow_key(a, f);
420            if !valid_id(&f.id) || !flows.insert(&f.id) {
421                errors.push(format!("{key}: invalid/duplicate flow ID"));
422            }
423            errors.extend(
424                validate_flow(f, &inputs.files)
425                    .into_iter()
426                    .map(|e| format!("{key}: {e}")),
427            );
428        }
429    }
430    let mut changes = BTreeSet::new();
431    for change in &map.change_assessments {
432        if !valid_id(&change.id) || !changes.insert(&change.id) {
433            errors.push("invalid/duplicate change assessment ID".into());
434        }
435    }
436    errors
437}
438/// Things worth telling the author that do not make the map wrong.
439///
440/// A redundant `watch` is the one that matters today. Supercov already marks
441/// every flow dirty when a dependency manifest or the execution configuration
442/// changes, so naming one of those files per flow catches nothing extra. It
443/// does teach a false model -- that per-flow watching is how dependency drift
444/// is caught -- and an author who believes it spends the effort on entries that
445/// change nothing instead of on the helper their claim actually rests on.
446pub fn advisories(map: &AssertionMap) -> Vec<String> {
447    let mut out = Vec::new();
448    for a in &map.assertions {
449        for f in &a.flows {
450            for file in &f.watch {
451                if crate::integrity::globally_tracked(file) {
452                    out.push(format!(
453                        "{}: watch \"{file}\" is redundant; Supercov invalidates every flow when that file changes",
454                        flow_key(a, f)
455                    ));
456                }
457            }
458        }
459    }
460    out
461}
462pub fn validate_flow(flow: &Flow, files: &Files) -> Vec<String> {
463    let mut errors = Vec::new();
464    let mut nodes = BTreeSet::new();
465    for node in &flow.nodes {
466        if !valid_id(&node.id) || !nodes.insert(&node.id) {
467            errors.push("invalid/duplicate node ID".into());
468        }
469        if node.at.offset(files).is_none() {
470            errors.push(format!("node {}: invalid anchor", node.id));
471        }
472    }
473    for edge in &flow.edges {
474        if !nodes.contains(&edge.from) || (!nodes.contains(&edge.to) && edge.to != "$assertion") {
475            errors.push("dangling edge".into());
476        }
477    }
478    if flow.counts_as_asserted.iter().any(|id| !nodes.contains(id)) {
479        errors.push("unknown counted node".into());
480    }
481    // Traverse only the author's graph. Never infer a dependency from source.
482    let mut reaches = BTreeSet::from(["$assertion".to_owned()]);
483    loop {
484        let size = reaches.len();
485        for edge in &flow.edges {
486            if reaches.contains(&edge.to) {
487                reaches.insert(edge.from.clone());
488            }
489        }
490        if reaches.len() == size {
491            break;
492        }
493    }
494    for id in &flow.counts_as_asserted {
495        if !reaches.contains(id) {
496            errors.push(format!(
497                "counted node {id} has no authored path to $assertion"
498            ));
499        }
500    }
501    if flow.edges.iter().any(|e| e.kind.trim().is_empty()) {
502        errors.push("missing edge kind".into());
503    }
504    if flow
505        .applies_to
506        .iter()
507        .any(|t| !local_path(&t.file) || !files.contains_key(&t.file) || t.name.trim().is_empty())
508    {
509        errors.push("invalid test selector file or name".into());
510    }
511    if flow.applies_to.iter().collect::<BTreeSet<_>>().len() != flow.applies_to.len() {
512        errors.push("duplicate test selector".into());
513    }
514    if flow
515        .counts_as_asserted
516        .iter()
517        .collect::<BTreeSet<_>>()
518        .len()
519        != flow.counts_as_asserted.len()
520    {
521        errors.push("duplicate counted node".into());
522    }
523    if flow.explanation.trim().is_empty() {
524        errors.push("missing explanation".into());
525    }
526    for file in &flow.watch {
527        if !local_path(file) || !files.contains_key(file) {
528            errors.push(format!("watched file missing: {file}"));
529        }
530    }
531    errors
532}
533
534/// Whole-file input dependencies, not a mechanically inferred semantic slice.
535pub fn dependencies<'a>(a: &'a Assertion, f: &'a Flow) -> BTreeSet<&'a str> {
536    std::iter::once(a.at.file.as_str())
537        .chain(f.applies_to.iter().map(|t| t.file.as_str()))
538        .chain(f.nodes.iter().map(|n| n.at.file.as_str()))
539        // A watch on a file Supercov already answers for run-wide contributes
540        // nothing here, and hashing its bytes would quietly undo the manifest
541        // rule: a version bump would still make every flow that names
542        // `package.json` stale, which is most of them in a real map. The
543        // run-level signal still fires, as a change to assess.
544        //
545        // Only the watch list is filtered. An anchor or a node in one of those
546        // files is the flow's actual subject -- `setup.py` is a dependency
547        // manifest and measured source at once -- and editing it must still
548        // cost a review.
549        .chain(
550            f.watch
551                .iter()
552                .map(String::as_str)
553                .filter(|path| !crate::integrity::globally_tracked(path)),
554        )
555        .collect()
556}
557fn token(value: &impl Serialize) -> String {
558    format!("scov2:{}", digest(value))
559}
560pub fn change_errors(
561    map: &AssertionMap,
562    change: &Change,
563    response: &ChangeAssessment,
564) -> Vec<String> {
565    let keys = map
566        .assertions
567        .iter()
568        .flat_map(|a| a.flows.iter().map(move |f| flow_key(a, f)))
569        .collect::<BTreeSet<_>>();
570    let affected = response
571        .affected_flows
572        .iter()
573        .cloned()
574        .collect::<BTreeSet<_>>();
575    let mut errors = Vec::new();
576    if response.explanation.trim().is_empty() {
577        errors.push("missing impact explanation".into());
578    }
579    if affected.len() != response.affected_flows.len() {
580        errors.push("duplicate affected flow".into());
581    }
582    if !affected.is_subset(&keys) {
583        errors.push("unknown affected flow".into());
584    }
585    if !change
586        .known_flows
587        .intersection(&keys)
588        .all(|k| affected.contains(k))
589    {
590        errors.push("known dependent flows must be included unless removed from the map".into());
591    }
592    errors
593}
594pub fn expected_change_basis(
595    change: &Change,
596    response: &ChangeAssessment,
597    inputs: &InputManifest,
598) -> String {
599    token(&(
600        "supercov-change-v2",
601        change,
602        digest(inputs),
603        &response.id,
604        &response.affected_flows,
605        &response.explanation,
606    ))
607}
608pub fn change_current(map: &AssertionMap, change: &Change, inputs: &InputManifest) -> bool {
609    let responses = map
610        .change_assessments
611        .iter()
612        .filter(|r| r.id == change.id)
613        .collect::<Vec<_>>();
614    matches!(responses.as_slice(), [r] if change_errors(map, change, r).is_empty() && r.basis.as_deref() == Some(expected_change_basis(change, r, inputs).as_str()))
615}
616fn generation(
617    a: &Assertion,
618    f: &Flow,
619    map: &AssertionMap,
620    state: &State,
621    inputs: &InputManifest,
622) -> String {
623    let key = flow_key(a, f);
624    let base = state.flows.get(&key).map_or("0", |s| s.generation.as_str());
625    let impacts = state
626        .changes
627        .iter()
628        .filter(|c| change_current(map, c, inputs))
629        .filter_map(|c| {
630            map.change_assessments
631                .iter()
632                .find(|r| r.id == c.id && r.affected_flows.contains(&key))
633                .map(|r| (&c.id, &r.basis))
634        })
635        .collect::<BTreeMap<_, _>>();
636    if impacts.is_empty() {
637        base.into()
638    } else {
639        digest(&("supercov-generation-v2", base, impacts))
640    }
641}
642pub fn expected_basis(
643    a: &Assertion,
644    f: &Flow,
645    map: &AssertionMap,
646    state: &State,
647    inputs: &InputManifest,
648) -> String {
649    let mut claim = f.clone();
650    claim.basis = None;
651    let hashes = dependencies(a, f)
652        .into_iter()
653        .map(|p| (p, inputs.files.get(p)))
654        .collect::<BTreeMap<_, _>>();
655    token(&(
656        "supercov-flow-v2",
657        &inputs.context_digest,
658        &a.id,
659        &a.at,
660        &a.observes,
661        claim,
662        hashes,
663        generation(a, f, map, state, inputs),
664    ))
665}
666pub fn reasons(
667    a: &Assertion,
668    f: &Flow,
669    map: &AssertionMap,
670    state: &State,
671    inputs: &Inputs,
672) -> BTreeSet<String> {
673    reasons_for_manifest(a, f, map, state, inputs, &inputs.manifest())
674}
675pub fn reasons_for_manifest(
676    a: &Assertion,
677    f: &Flow,
678    map: &AssertionMap,
679    state: &State,
680    inputs: &Inputs,
681    manifest: &InputManifest,
682) -> BTreeSet<String> {
683    let mut reasons = BTreeSet::new();
684    if state.schema_version != 3 || state.inputs_digest != digest(manifest) {
685        reasons.insert("state does not match run inputs".into());
686    }
687    if f.basis.as_deref() != Some(expected_basis(a, f, map, state, manifest).as_str()) {
688        reasons.insert(
689            if f.basis.is_none() {
690                "draft: input acknowledgement not recorded"
691            } else {
692                "claim or inputs changed; needs rechecking"
693            }
694            .into(),
695        );
696        if let Some(s) = state.flows.get(&flow_key(a, f)) {
697            reasons.extend(s.reasons.iter().cloned());
698        }
699    }
700    reasons.extend(validate_flow(f, &inputs.files));
701    if a.at.offset(&inputs.files).is_none() {
702        reasons.insert("invalid assertion anchor".into());
703    }
704    if !f.questions.is_empty() {
705        reasons.insert("flow has unresolved questions".into());
706    }
707    reasons
708}
709/// Read-only validation. Tokens acknowledge authored claims, never prove them.
710pub fn validation(map: &AssertionMap, state: &State, inputs: &Inputs) -> serde_json::Value {
711    use serde_json::json;
712    let manifest = inputs.manifest();
713    let mut errors = validate(map, inputs);
714    for r in &map.change_assessments {
715        if !state.changes.iter().any(|c| c.id == r.id) {
716            errors.push(format!("{}: unknown change assessment", r.id));
717        }
718    }
719    let changes = state.changes.iter().map(|c| {
720        let response = map.change_assessments.iter().find(|r| r.id == c.id);
721        let faults = response.map(|r| change_errors(map, c, r)).unwrap_or_default();
722        errors.extend(faults.iter().map(|e| format!("{}: {e}", c.id)));
723        json!({"id":c.id,"file":c.file,"before":c.before,"after":c.after,"reason":c.reason,"knownFlows":c.known_flows,
724            "current":change_current(map,c,&manifest),"assessment":response,"errors":faults,
725            "expectedBasis":response.map(|r| expected_change_basis(c,r,&manifest))})
726    }).collect::<Vec<_>>();
727    let flows = map.assertions.iter().flat_map(|a| a.flows.iter().map(move |f| (a,f))).map(|(a,f)| {
728        json!({"id":flow_key(a,f),"expectedBasis":expected_basis(a,f,map,state,&manifest),"reasons":reasons_for_manifest(a,f,map,state,inputs,&manifest)})
729    }).collect::<Vec<_>>();
730    json!({"valid":errors.is_empty(),"stage":"references","errors":errors,"flows":flows,"changes":changes,
731        "meaning":"Authored graph references and input acknowledgements only; no semantic proof or completeness claim"})
732}
733pub fn invalidate(state: &mut State, map: &AssertionMap, reason: &str) {
734    for a in &map.assertions {
735        for f in &a.flows {
736            let key = flow_key(a, f);
737            let base = state.flows.get(&key).map_or("0", |s| s.generation.as_str());
738            state.flows.insert(
739                key,
740                FlowState {
741                    generation: digest(&(base, reason, &state.inputs_digest)),
742                    reasons: BTreeSet::from([reason.into()]),
743                },
744            );
745        }
746    }
747}
748pub fn add_change(
749    state: &mut State,
750    file: Option<String>,
751    before: Option<String>,
752    after: Option<String>,
753    reason: String,
754    known_flows: BTreeSet<String>,
755) {
756    // Include pending history so edit/revert/edit cannot alias a still-pending event.
757    let id = format!(
758        "c_{}",
759        &digest(&(
760            "supercov-change-id-v2",
761            &state.changes,
762            &file,
763            &before,
764            &after,
765            &reason
766        ))[..24]
767    );
768    state.changes.push(Change {
769        id,
770        file,
771        before,
772        after,
773        reason,
774        known_flows,
775    });
776}
777
778fn unique_occurrence(text: &str, snippet: &str) -> Option<usize> {
779    if snippet.is_empty() {
780        return None;
781    }
782    let first = text.find(snippet)?;
783    // Include overlapping occurrences; match_indices skips them.
784    let next = first + text[first..].chars().next()?.len_utf8();
785    text[next..]
786        .contains(snippet)
787        .then_some(())
788        .map_or(Some(first), |_| None)
789}
790fn target_file(file: &str, old: &FileManifest, new: &Files) -> Option<String> {
791    if new.contains_key(file) {
792        return Some(file.into());
793    }
794    let hash = old.get(file)?;
795    let mut matches = new.iter().filter(|(_, s)| FileFingerprint::of(s) == *hash);
796    let first = matches.next()?.0;
797    matches.next().is_none().then(|| first.clone())
798}
799pub fn relocate(at: &Anchor, old: &FileManifest, new: &Files) -> Option<Anchor> {
800    let before = old.get(&at.file)?;
801    let target = target_file(&at.file, old, new)?;
802    let after = &new[&target];
803    let mut candidate = at.clone();
804    candidate.file.clone_from(&target);
805    if FileFingerprint::of(after) == *before && candidate.offset(new).is_some() {
806        return Some(candidate);
807    }
808    let position = unique_occurrence(after, &at.text)?;
809    Some(Anchor::new(
810        &target,
811        after,
812        position,
813        position + at.text.len(),
814    ))
815}
816
817/// Carries explanations, never execution events. Uncertain matches are retained
818/// as retired suggestions; no nearest-line heuristic assigns semantic meaning.
819pub fn carry(
820    map: &AssertionMap,
821    state: &State,
822    old: &InputManifest,
823    new: &Inputs,
824    evidence_digest: &str,
825    context_changed: bool,
826) -> Result<(AssertionMap, State), String> {
827    if map.schema_version != 2 || old.schema_version != 2 || new.schema_version != 1 {
828        return Err("unsupported map/input schema version".into());
829    }
830    if state.inputs_digest != digest(old) || state.schema_version != 3 {
831        return Err("old map state does not match its run inputs".into());
832    }
833    let new_manifest = new.manifest();
834    let (mut next, mut next_state) = seed_manifest(&new_manifest, evidence_digest);
835    next.assertions.clear();
836    next.retired_assertions = map.retired_assertions.clone();
837    next_state.changes = state
838        .changes
839        .iter()
840        .filter(|c| !change_current(map, c, old))
841        .cloned()
842        .collect();
843    next.change_assessments = map
844        .change_assessments
845        .iter()
846        .filter(|r| next_state.changes.iter().any(|c| c.id == r.id))
847        .cloned()
848        .collect();
849    let mut consumed = BTreeSet::new();
850    let exact = map
851        .assertions
852        .iter()
853        .map(|a| {
854            relocate(&a.at, &old.files, &new.files).filter(|at| {
855                new.assertions.iter().any(|s| &s.at == at)
856                    || !old.assertions.iter().any(|s| s.at == a.at)
857            })
858        })
859        .collect::<Vec<_>>();
860    let reserved = exact.iter().flatten().collect::<BTreeSet<_>>();
861    for (index, a) in map.assertions.iter().enumerate() {
862        // A sole old/new unmatched site in the same file is a review
863        // suggestion. Preserve its explanation but never its reviewed status.
864        let candidates = new
865            .assertions
866            .iter()
867            .filter(|s| s.at.file == a.at.file && !reserved.contains(&s.at))
868            .collect::<Vec<_>>();
869        let unmatched = map
870            .assertions
871            .iter()
872            .zip(&exact)
873            .filter(|(other, at)| other.at.file == a.at.file && at.is_none())
874            .count();
875        let replacement = if exact[index].is_none() && unmatched == 1 && candidates.len() == 1 {
876            Some(&candidates[0].at)
877        } else {
878            None
879        };
880        let matched = exact[index]
881            .as_ref()
882            .or(replacement)
883            .filter(|at| !consumed.contains(*at));
884        let Some(at) = matched else {
885            next.retired_assertions.push(Retired {
886                assertion: a.clone(),
887                reason:
888                    "assertion removed, changed or ambiguous; reuse its explanation after review"
889                        .into(),
890            });
891            continue;
892        };
893        consumed.insert(at.clone());
894        let mut updated = a.clone();
895        updated.at = at.clone();
896        for (prior, f) in a.flows.iter().zip(&mut updated.flows) {
897            let base = generation(a, prior, map, state, old);
898            let mut dirty = BTreeSet::new();
899            if prior
900                .basis
901                .as_deref()
902                .is_some_and(|basis| basis != expected_basis(a, prior, map, state, old))
903            {
904                dirty.insert("inherited claim still needs rechecking".into());
905            }
906            for file in dependencies(a, prior) {
907                if old.files.get(file) != new_manifest.files.get(file)
908                    || !old.files.contains_key(file)
909                {
910                    dirty.insert(format!("dependency file changed or removed: {file}"));
911                }
912            }
913            if replacement.is_some() {
914                dirty.insert("assertion changed or replaced; confirm identity and meaning".into());
915            }
916            for node in &mut f.nodes {
917                if let Some(at) = relocate(&node.at, &old.files, &new.files) {
918                    node.at = at;
919                } else {
920                    dirty.insert(format!("node {} changed or ambiguous", node.id));
921                }
922            }
923            for file in f
924                .watch
925                .iter_mut()
926                .chain(f.applies_to.iter_mut().map(|t| &mut t.file))
927            {
928                if let Some(target) = target_file(file, &old.files, &new.files) {
929                    *file = target;
930                } else {
931                    dirty.insert(format!("dependency file removed: {file}"));
932                }
933            }
934            if context_changed {
935                dirty.insert("run configuration, dependencies or execution context changed".into());
936            }
937            next_state.flows.insert(
938                flow_key(a, f),
939                FlowState {
940                    generation: if dirty.is_empty() {
941                        base
942                    } else {
943                        digest(&("supercov-carry-v2", base, &new_manifest, &dirty))
944                    },
945                    reasons: dirty,
946                },
947            );
948        }
949        next.assertions.push(updated);
950    }
951    let mut ids = map
952        .assertions
953        .iter()
954        .map(|a| a.id.clone())
955        .chain(
956            map.retired_assertions
957                .iter()
958                .map(|r| r.assertion.id.clone()),
959        )
960        .collect::<BTreeSet<_>>();
961    for a in seed(new, evidence_digest).0.assertions {
962        if !consumed.contains(&a.at) {
963            let mut a = a;
964            while !ids.insert(a.id.clone()) {
965                a.id.push('_');
966            }
967            next.assertions.push(a);
968        }
969    }
970    for file in old
971        .files
972        .keys()
973        .chain(new_manifest.files.keys())
974        .collect::<BTreeSet<_>>()
975    {
976        // A manifest is answered for by the run's dependency fingerprint, which
977        // reads what it declares. Reporting its bytes here as well would make
978        // cutting a release look like a change to assess when nothing about the
979        // project moved.
980        if crate::integrity::tracked_manifest(file) {
981            continue;
982        }
983        if old.files.get(file) != new_manifest.files.get(file) {
984            let known = map
985                .assertions
986                .iter()
987                .flat_map(|a| {
988                    a.flows
989                        .iter()
990                        .filter(|f| dependencies(a, f).contains(file.as_str()))
991                        .map(move |f| flow_key(a, f))
992                })
993                .collect();
994            add_change(
995                &mut next_state,
996                Some(file.clone()),
997                old.files.get(file).map(|f| f.sha256.clone()),
998                new_manifest.files.get(file).map(|f| f.sha256.clone()),
999                "captured source file changed".into(),
1000                known,
1001            );
1002        }
1003    }
1004    next.assertions.sort_by(|a, b| a.at.cmp(&b.at));
1005    Ok((next, next_state))
1006}
1007
1008#[path = "assertion_legacy.rs"]
1009mod legacy;
1010pub fn parse_stored(bytes: &[u8]) -> Result<AssertionMap, String> {
1011    let value: serde_json::Value = serde_json::from_slice(bytes).map_err(|e| e.to_string())?;
1012    match value
1013        .get("schemaVersion")
1014        .and_then(serde_json::Value::as_u64)
1015    {
1016        None | Some(1) => legacy::import(bytes),
1017        _ => parse(bytes).map_err(|e| e.to_string()),
1018    }
1019}
1020pub fn parse_state(
1021    bytes: &[u8],
1022    map: &AssertionMap,
1023    inputs: &InputManifest,
1024    evidence: &str,
1025    legacy_digest: Option<&str>,
1026) -> Result<State, String> {
1027    let value: serde_json::Value = serde_json::from_slice(bytes).map_err(|e| e.to_string())?;
1028    if value["schemaVersion"] == 3 {
1029        let state: State = serde_json::from_slice(bytes).map_err(|e| e.to_string())?;
1030        if state.inputs_digest != digest(inputs) || state.evidence_digest != evidence {
1031            return Err("Assertion state belongs to different run evidence; rerun tests".into());
1032        }
1033        Ok(state)
1034    } else {
1035        legacy::state(bytes, map, inputs, evidence, legacy_digest)
1036    }
1037}