Skip to main content

varve_core/
linestatus.rs

1//! Line-status documents (REQ-KP-001, DD-008) — signed, updatable evidence
2//! beside immutable layers.
3//!
4//! One document per release line carries what changes *after* deposit:
5//! known problems (the Ferrocene schema: workaround, detection, mitigation,
6//! affected layers), the support window, and yank markers. It is DSSE-signed
7//! with the same root as layers but under its own payload type, carries its
8//! own monotonic counter, and is cached per line so `varve status` answers
9//! offline. A yanked layer warns loudly but remains installable — the
10//! consumer owns the freeze decision.
11
12use std::collections::BTreeMap;
13use std::path::{Path, PathBuf};
14
15use serde::{Deserialize, Serialize};
16
17use crate::install::VerifyError;
18use crate::layer::{LayerId, Line};
19use crate::verify::{dsse_sign_typed, dsse_verify_typed};
20
21/// The authenticated payload type — a signed something-else cannot be
22/// replayed as a status document.
23pub const LINE_STATUS_PAYLOAD_TYPE: &str = "application/vnd.pulseengine.varve.line-status.v1+json";
24
25#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
26#[serde(deny_unknown_fields)]
27pub struct KnownProblem {
28    pub id: String,
29    pub title: String,
30    pub severity: String,
31    /// Layers affected, e.g. ["2026.07.0", "2026.07.1"].
32    pub affected: Vec<String>,
33    #[serde(skip_serializing_if = "Option::is_none", default)]
34    pub workaround: Option<String>,
35    #[serde(skip_serializing_if = "Option::is_none", default)]
36    pub detection: Option<String>,
37    #[serde(skip_serializing_if = "Option::is_none", default)]
38    pub mitigation: Option<String>,
39}
40
41#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
42#[serde(deny_unknown_fields)]
43pub struct LineStatus {
44    /// The release line, e.g. "2026.07".
45    pub line: String,
46    /// Monotonic per-line document counter — a stale advisory must not
47    /// silently replace a newer one.
48    pub counter: u64,
49    /// RFC 3339 issue time of this document.
50    #[serde(rename = "issued-at")]
51    pub issued_at: String,
52    /// Stated support window end, RFC 3339 date, if committed.
53    #[serde(
54        rename = "support-until",
55        skip_serializing_if = "Option::is_none",
56        default
57    )]
58    pub support_until: Option<String>,
59    /// Yanked layers of this line, layer → reason.
60    #[serde(skip_serializing_if = "BTreeMap::is_empty", default)]
61    pub yanked: BTreeMap<String, String>,
62    #[serde(
63        rename = "known-problems",
64        skip_serializing_if = "Vec::is_empty",
65        default
66    )]
67    pub known_problems: Vec<KnownProblem>,
68}
69
70#[derive(Debug, thiserror::Error)]
71pub enum LineStatusError {
72    /// The DSSE envelope failed verification or was malformed. Carries the
73    /// verifier's reason verbatim — but under a line-status heading, because
74    /// the old `#[error(transparent)]` route surfaced these as "manifest
75    /// signature verification failed", sending the reader to the wrong
76    /// document entirely (varve#60).
77    #[error("line-status envelope rejected: {0}")]
78    Envelope(String),
79    #[error("cannot sign the line-status document: {0}")]
80    Sign(String),
81    #[error("line-status payload is not valid: {0}")]
82    Payload(String),
83    #[error("line-status covers line {got} but line {expected} was requested")]
84    LineMismatch { expected: String, got: String },
85    #[error(
86        "refusing stale line-status document for {line}: presented counter {presented}, cached {cached}"
87    )]
88    Stale {
89        line: String,
90        presented: u64,
91        cached: u64,
92    },
93    /// An advisory entry that can never fire (varve#61): `varve status`
94    /// matches `affected` ids and yank keys against installed layer ids
95    /// EXACTLY, so a typo'd id signs fine and then warns nobody. Refused on
96    /// the producing side, where the fix (re-sign) is still cheap.
97    #[error(
98        "{what} names layer '{id}', which is not a layer of line {line} ({reason}) — `varve \
99         status` matches layer ids exactly, so this entry would never fire for any installed \
100         layer; fix the id and re-sign the document"
101    )]
102    DeadReference {
103        what: String,
104        id: String,
105        line: String,
106        reason: String,
107    },
108    /// REQ-ADVISORY-002. The id is a well-formed layer of this line and names
109    /// no layer that EXISTS — a typo one character deep. It signs cleanly, the
110    /// producer sees success, the consumer sees nothing, and the yank silently
111    /// does not exist. Refused wherever the signer can see the line's layers;
112    /// `--force` is for the legitimate case of pre-signing an advisory for a
113    /// layer not deposited yet.
114    #[error(
115        "{what} names layer '{id}', which line {line} does not contain — it exposes: {existing}. \
116         `varve status` matches layer ids EXACTLY, so this entry would fire for nobody: you \
117         would see success, every consumer would see nothing, and the advisory would silently \
118         not exist. Fix the id, or pass --force to pre-sign an advisory for a layer that is not \
119         deposited yet."
120    )]
121    UnknownLayer {
122        what: String,
123        id: String,
124        line: String,
125        existing: String,
126    },
127    #[error(
128        "{layout} is not an OCI image layout (it has no index.json) — point --layout at the \
129         directory `varve deposit --out` produced"
130    )]
131    NotALayout { layout: String },
132    // The io source is NOT repeated in the message: anyhow's `{err:#}` chain
133    // already appends every source, and including it here printed the cause
134    // twice ("… No such file or directory: No such file or directory").
135    #[error("io error at {path}")]
136    Io {
137        path: String,
138        #[source]
139        source: std::io::Error,
140    },
141}
142
143/// What the signer could see of a line's layers when it validated an advisory
144/// (REQ-ADVISORY-002).
145///
146/// The distinction is the whole point. An `affected` id is only checkable
147/// against a LISTING of the line — the realm's signed line-index. A deposit
148/// layout holds one layer, so it is not a listing, and treating it as one
149/// would refuse advisories about layers that exist perfectly well elsewhere.
150/// Where no listing is in reach the check cannot be run, and the caller must
151/// be told WHICH check was skipped rather than handed a success that implies
152/// a complete one.
153#[derive(Debug, Clone, PartialEq, Eq)]
154pub enum KnownLayers {
155    /// The ids the signer could enumerate, and where they came from.
156    Known {
157        /// For the note: which document these came out of.
158        source: String,
159        /// The line the listing covers, when it names one — a listing for
160        /// another line is not a listing for this one.
161        line: Option<String>,
162        layers: Vec<String>,
163    },
164    /// The signer could not see the line's layers, and why not.
165    Unknown { why: String },
166}
167
168impl KnownLayers {
169    /// The realm's own statement of which layers a line has — the only
170    /// authoritative listing varve has.
171    pub fn from_index(index: &crate::lineindex::LineIndex) -> Self {
172        KnownLayers::Known {
173            source: format!(
174                "the signed line-index for {} (counter {})",
175                index.line, index.counter
176            ),
177            line: Some(index.line.clone()),
178            layers: index.layers.iter().map(|e| e.layer.clone()).collect(),
179        }
180    }
181
182    pub fn unknown(why: impl Into<String>) -> Self {
183        KnownLayers::Unknown { why: why.into() }
184    }
185}
186
187/// Which validation actually ran, so a caller reports the check it performed
188/// and not the one it did not (REQ-ADVISORY-002).
189#[derive(Debug, Clone, PartialEq, Eq)]
190pub struct RefCheck {
191    /// True only when every id was matched against a real listing of the
192    /// line's layers.
193    pub existence_checked: bool,
194    /// One line for the operator: the check that ran, or the one that did not.
195    pub note: String,
196}
197
198/// The layers a signed line-index asserts, verified against the root that is
199/// about to sign the advisory (REQ-ADVISORY-002 clause 2).
200///
201/// Verified, not merely parsed: an unverified listing is one an attacker could
202/// choose, and one that names the typo'd layer would wave the dead advisory
203/// through. At sign time the producer holds the key, so the check is free.
204pub fn known_layers_from_index(
205    envelope: &[u8],
206    root_public_key: &[u8],
207) -> Result<KnownLayers, crate::lineindex::IndexError> {
208    let doc = crate::lineindex::LineIndex::verify_and_parse(envelope, root_public_key)?;
209    Ok(KnownLayers::from_index(&doc))
210}
211
212/// The line's layers as the PRODUCER can see them, from their own layouts —
213/// no network, and no published index required (REQ-ADVISORY-002 clause 5,
214/// DD-023).
215///
216/// `signed-index` is false by default, so an index-only existence check would
217/// rarely have anything to check against: opt-in safety, which is how a typo'd
218/// `affected` id came to sign cleanly and fire for nobody. The producer already
219/// HOLDS the layers on disk. That listing is more trustworthy than a registry's
220/// — a compromised registry can hide a layer and thereby block the yank of the
221/// very layer it hides (the reason DD-023 keeps the network out of the signing
222/// command) — and it works for a realm that never publishes an index at all.
223///
224/// Each path is either a layout directory or a directory OF layout
225/// directories, because a producer's output tree is usually the latter.
226pub fn known_layers_in_layout_dirs(dirs: &[std::path::PathBuf], line: &str) -> KnownLayers {
227    let mut layers: Vec<String> = Vec::new();
228    let mut scanned = 0usize;
229    let visit = |dir: &Path, layers: &mut Vec<String>| {
230        // A real `varve deposit --out` writes an OCI layout: index.json plus
231        // blobs/sha256/. The bare `manifests/`+`blobs/` shape is the other
232        // source form varve accepts. Both are read, because a fixture that
233        // only spoke one of them is how this function first shipped passing a
234        // test against bytes the tool never produces.
235        let mut candidates: Vec<Vec<u8>> = Vec::new();
236        if let Ok(index) = std::fs::read(dir.join("index.json"))
237            && let Ok(idx) = serde_json::from_slice::<serde_json::Value>(&index)
238        {
239            for m in idx
240                .get("manifests")
241                .and_then(|m| m.as_array())
242                .into_iter()
243                .flatten()
244            {
245                if let Some(d) = m.get("digest").and_then(|d| d.as_str())
246                    && let Some((_, hex)) = d.split_once(':')
247                    && let Ok(b) = std::fs::read(dir.join("blobs").join("sha256").join(hex))
248                {
249                    candidates.push(b);
250                }
251            }
252        }
253        if let Ok(entries) = std::fs::read_dir(dir.join("manifests")) {
254            for e in entries.filter_map(|e| e.ok()) {
255                if let Ok(b) = std::fs::read(e.path()) {
256                    candidates.push(b);
257                }
258            }
259        }
260        if candidates.is_empty() {
261            return false;
262        }
263        for bytes in candidates {
264            // A layout stores the SIGNED envelope; the layer id lives in its
265            // payload. Unverified is correct here: this is the producer's own
266            // output tree, and the id is being used to catch a typo, not to
267            // decide trust.
268            let payload = std::str::from_utf8(&bytes)
269                .ok()
270                .and_then(|t| wsc::dsse::DsseEnvelope::from_json(t).ok())
271                .and_then(|env| env.payload_bytes().ok())
272                .unwrap_or_else(|| bytes.clone());
273            if let Ok(m) = crate::manifest::LayerManifest::parse(&payload) {
274                let id = m.layer.to_string();
275                if id.starts_with(&format!("{line}.")) && !layers.contains(&id) {
276                    layers.push(id);
277                }
278            }
279        }
280        true
281    };
282    for dir in dirs {
283        if visit(dir, &mut layers) {
284            scanned += 1;
285            continue;
286        }
287        // Not a layout itself — try its children.
288        if let Ok(children) = std::fs::read_dir(dir) {
289            for c in children.filter_map(|e| e.ok()) {
290                if c.path().is_dir() && visit(&c.path(), &mut layers) {
291                    scanned += 1;
292                }
293            }
294        }
295    }
296    if scanned == 0 {
297        return KnownLayers::unknown(format!(
298            "no oci-layout was found under {} — pass the directory `varve deposit --out` \
299             wrote, or one holding several of them",
300            dirs.iter()
301                .map(|d| d.display().to_string())
302                .collect::<Vec<_>>()
303                .join(", ")
304        ));
305    }
306    layers.sort();
307    KnownLayers::Known {
308        source: format!("{scanned} local layout(s) the producer holds"),
309        line: Some(line.to_string()),
310        layers,
311    }
312}
313
314/// What a deposit layout can tell a producer about the line's layers.
315///
316/// A layout carries the realm's signed index only once `attach-index` has run,
317/// and the documented CI order attaches the status FIRST — so the ordinary
318/// answer here is `Unknown`, stated plainly rather than passed off as a clean
319/// bill of health.
320pub fn known_layers_in_layout(layout: &Path, line: &str) -> KnownLayers {
321    match crate::lineindex::read_from_layout(layout, line) {
322        Ok(Some(envelope)) => match crate::lineindex::parse_unverified(&envelope) {
323            Ok(doc) if doc.line == line => KnownLayers::from_index(&doc),
324            Ok(doc) => KnownLayers::unknown(format!(
325                "the line-index this layout carries is for line {}, not {line}",
326                doc.line
327            )),
328            Err(e) => KnownLayers::unknown(format!(
329                "the line-index this layout carries could not be read ({e})"
330            )),
331        },
332        Ok(None) => KnownLayers::unknown(format!(
333            "this layout carries no signed line-index for {line}, and a layout holds ONE layer \
334             — it is not a listing of the line. Attach the index first (`varve attach-index`), \
335             or sign against it (`varve sign-status --index <envelope>`)"
336        )),
337        Err(e) => KnownLayers::unknown(format!("the layout's index.json could not be read ({e})")),
338    }
339}
340
341impl LineStatus {
342    /// Verify an envelope against the trust root and parse the payload.
343    pub fn verify_and_parse(
344        envelope: &[u8],
345        root_public_key: &[u8],
346    ) -> Result<Self, LineStatusError> {
347        // Diagnose the not-an-envelope case BEFORE the verifier does: its
348        // wrapped parser error prints the cause twice and never names the
349        // commonest mistake — handing over the raw status JSON instead of the
350        // signed envelope (varve#60).
351        if let Ok(text) = std::str::from_utf8(envelope)
352            && wsc::dsse::DsseEnvelope::from_json(text).is_err()
353        {
354            return Err(not_an_envelope(text));
355        }
356        let payload = dsse_verify_typed(envelope, LINE_STATUS_PAYLOAD_TYPE, root_public_key)
357            .map_err(|VerifyError(msg)| {
358                // A wrong-realm signature is indistinguishable from tampering
359                // at this layer; say so, because "No valid signatures" alone
360                // sends the reader hunting for corruption.
361                let hint = if msg.contains("does not verify") {
362                    " (is the document signed by THIS realm's root? `varve pubkey <key>` \
363                     prints the public half a signature verifies against)"
364                } else {
365                    ""
366                };
367                LineStatusError::Envelope(format!("{msg}{hint}"))
368            })?;
369        serde_json::from_slice(&payload).map_err(|e| LineStatusError::Payload(e.to_string()))
370    }
371
372    /// Sign a status document (the producing side — CI, next to deposit).
373    /// Refuses a document whose yank or `affected` entries could never fire
374    /// (varve#61) — a typo'd layer id is cheapest to fix before the signature
375    /// exists.
376    pub fn sign(&self, secret_key: &[u8], key_id: &str) -> Result<String, LineStatusError> {
377        self.check_layer_refs()?;
378        let payload = serde_json::to_vec_pretty(self).expect("status serializes");
379        dsse_sign_typed(&payload, LINE_STATUS_PAYLOAD_TYPE, secret_key, key_id)
380            .map_err(|VerifyError(msg)| LineStatusError::Sign(msg))
381    }
382
383    /// Sign, having checked every advisory reference against what the signer
384    /// can actually see of the line (REQ-ADVISORY-002). Returns the envelope
385    /// and a statement of which check ran — a caller that prints only
386    /// "signed" implies a completeness it may not have.
387    pub fn sign_against(
388        &self,
389        known: &KnownLayers,
390        force: bool,
391        secret_key: &[u8],
392        key_id: &str,
393    ) -> Result<(String, RefCheck), LineStatusError> {
394        let check = self.check_layer_refs_against(known, force)?;
395        let payload = serde_json::to_vec_pretty(self).expect("status serializes");
396        let envelope = dsse_sign_typed(&payload, LINE_STATUS_PAYLOAD_TYPE, secret_key, key_id)
397            .map_err(|VerifyError(msg)| LineStatusError::Sign(msg))?;
398        Ok((envelope, check))
399    }
400
401    /// Every yank key and every `affected` id, checked as far as this signer
402    /// can see (REQ-ADVISORY-002).
403    ///
404    /// Two checks, deliberately separated:
405    ///
406    ///  * SHAPE and line membership — always run, never overridable. An id
407    ///    that is not a well-formed layer identifier of this line cannot
408    ///    become correct later, so `--force` has nothing to allow.
409    ///  * EXISTENCE — run only where a listing of the line is in reach.
410    ///    `--force` allows it through, because pre-signing an advisory for a
411    ///    layer about to be deposited is a legitimate thing to do. Silence is
412    ///    not: where the check does not run, the returned `RefCheck` says so.
413    pub fn check_layer_refs_against(
414        &self,
415        known: &KnownLayers,
416        force: bool,
417    ) -> Result<RefCheck, LineStatusError> {
418        self.check_layer_refs()?;
419        // How many layer ids this document actually asserts anything about.
420        // A BASELINE status — no yanks, no known problems — refers to nothing,
421        // so there is nothing an existence check could have caught. Warning
422        // there is cry-wolf: it fires on the correct setup `docs own-realm`
423        // tells every new operator to perform, and REQ-SHADOW-001's own lesson
424        // is that a check which fires on correct setups is one people switch
425        // off. Found by clean-room review, which noted it also printed TWICE
426        // (sign-status and attach-status) on the documented happy path.
427        let referenced = self.yanked.len()
428            + self
429                .known_problems
430                .iter()
431                .map(|p| p.affected.len())
432                .sum::<usize>();
433        let (source, layers) = match known {
434            KnownLayers::Unknown { .. } if referenced == 0 => {
435                return Ok(RefCheck {
436                    existence_checked: true,
437                    note: "this document names no layer — nothing to check against the line"
438                        .to_string(),
439                });
440            }
441            KnownLayers::Unknown { why } => {
442                return Ok(RefCheck {
443                    existence_checked: false,
444                    note: format!(
445                        "advisory references were checked for SHAPE only — NOT against the \
446                         layers line {} actually has: {why}. An id naming a layer that does not \
447                         exist still signs cleanly here and fires for nobody.",
448                        self.line
449                    ),
450                });
451            }
452            KnownLayers::Known {
453                source,
454                line,
455                layers,
456            } => {
457                // A listing for another line answers a different question. It
458                // would either wave everything through or refuse everything,
459                // and both verdicts would be reported as if they meant
460                // something.
461                if let Some(listing_line) = line
462                    && listing_line != &self.line
463                {
464                    return Err(LineStatusError::LineMismatch {
465                        expected: self.line.clone(),
466                        got: listing_line.clone(),
467                    });
468                }
469                (source, layers)
470            }
471        };
472        if force {
473            return Ok(RefCheck {
474                existence_checked: false,
475                note: format!(
476                    "--force: advisory references were NOT checked against the layers line {} \
477                     has. An entry naming a layer that has not been deposited yet fires only \
478                     once it is.",
479                    self.line
480                ),
481            });
482        }
483        let existing = if layers.is_empty() {
484            "no layers at all — this line has none yet".to_string()
485        } else {
486            layers.join(", ")
487        };
488        let mut refs = 0usize;
489        let mut check = |what: String, id: &str| -> Result<(), LineStatusError> {
490            refs += 1;
491            if layers.iter().any(|l| l == id) {
492                return Ok(());
493            }
494            Err(LineStatusError::UnknownLayer {
495                what,
496                id: id.to_string(),
497                line: self.line.clone(),
498                existing: existing.clone(),
499            })
500        };
501        for id in self.yanked.keys() {
502            check("the yank entry".to_string(), id)?;
503        }
504        for kp in &self.known_problems {
505            for id in &kp.affected {
506                check(format!("known problem '{}'", kp.id), id)?;
507            }
508        }
509        Ok(RefCheck {
510            existence_checked: true,
511            note: format!(
512                "{refs} advisory reference{} checked against the {} layer{} {source} lists for \
513                 line {}",
514                if refs == 1 { "" } else { "s" },
515                layers.len(),
516                if layers.len() == 1 { "" } else { "s" },
517                self.line
518            ),
519        })
520    }
521
522    /// Every yank key and every known problem's `affected` id must be a layer
523    /// of THIS document's line (varve#61). `report_for` matches ids exactly,
524    /// and the cache is keyed per line, so an id outside the line — or one
525    /// that is not a layer id at all — is an advisory that signs fine and
526    /// then fires for nobody. Enforced wherever a producer commits the
527    /// document: `sign` and `attach_envelope_to_layout`.
528    pub fn check_layer_refs(&self) -> Result<(), LineStatusError> {
529        let line: Line = self.line.parse().map_err(|e| {
530            LineStatusError::Payload(format!("'{}' is not a YYYY.MM line: {e}", self.line))
531        })?;
532        let check = |what: String, id: &str| -> Result<(), LineStatusError> {
533            let dead = |reason: String| LineStatusError::DeadReference {
534                what: what.clone(),
535                id: id.to_string(),
536                line: self.line.clone(),
537                reason,
538            };
539            match id.parse::<LayerId>() {
540                Ok(layer) if layer.line() == &line => Ok(()),
541                Ok(layer) => Err(dead(format!("it belongs to line {}", layer.line()))),
542                Err(e) => Err(dead(e.to_string())),
543            }
544        };
545        for id in self.yanked.keys() {
546            check("the yank entry".to_string(), id)?;
547        }
548        for kp in &self.known_problems {
549            for id in &kp.affected {
550                check(format!("known problem '{}'", kp.id), id)?;
551            }
552        }
553        Ok(())
554    }
555
556    /// What this document says about one layer.
557    pub fn report_for(&self, layer: &LayerId) -> LayerStatusReport {
558        let name = layer.to_string();
559        let problems: Vec<&KnownProblem> = self
560            .known_problems
561            .iter()
562            .filter(|kp| kp.affected.iter().any(|a| a == &name))
563            .collect();
564        LayerStatusReport {
565            yanked_reason: self.yanked.get(&name).cloned(),
566            support_until: self.support_until.clone(),
567            problems_total: problems.len(),
568            problems_with_workaround: problems.iter().filter(|kp| kp.workaround.is_some()).count(),
569        }
570    }
571}
572
573#[derive(Debug, Clone, PartialEq, Eq)]
574pub struct LayerStatusReport {
575    pub yanked_reason: Option<String>,
576    pub support_until: Option<String>,
577    pub problems_total: usize,
578    pub problems_with_workaround: usize,
579}
580
581/// Per-line cache of the newest verified document, under the varve root.
582#[derive(Debug)]
583pub struct StatusCache {
584    dir: PathBuf,
585}
586
587impl StatusCache {
588    pub fn at_root(root: &Path) -> Self {
589        StatusCache {
590            dir: root.join("state").join("line-status"),
591        }
592    }
593
594    /// Store a VERIFIED envelope for its line, refusing counter regressions.
595    pub fn update(
596        &self,
597        line: &Line,
598        envelope: &[u8],
599        parsed: &LineStatus,
600    ) -> Result<(), LineStatusError> {
601        if let Some(cached) = self.load_parsed(line)?
602            && parsed.counter < cached.counter
603        {
604            return Err(LineStatusError::Stale {
605                line: line.to_string(),
606                presented: parsed.counter,
607                cached: cached.counter,
608            });
609        }
610        let io = |path: &Path, source: std::io::Error| LineStatusError::Io {
611            path: path.display().to_string(),
612            source,
613        };
614        std::fs::create_dir_all(&self.dir).map_err(|e| io(&self.dir, e))?;
615        let path = self.envelope_path(line);
616        std::fs::write(&path, envelope).map_err(|e| io(&path, e))?;
617        Ok(())
618    }
619
620    /// The cached envelope bytes for a line, unparsed, if any.
621    ///
622    /// Used by `archive` to carry the baseline across an air gap (varve#77).
623    /// Deliberately opaque: the caller re-attaches the bytes verbatim, and the
624    /// far side re-verifies against its own trust root — archiving must not
625    /// become a place where a document is re-signed or re-shaped.
626    pub fn envelope_bytes(&self, line: &Line) -> Result<Option<Vec<u8>>, LineStatusError> {
627        let path = self.envelope_path(line);
628        match std::fs::read(&path) {
629            Ok(bytes) => Ok(Some(bytes)),
630            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
631            Err(source) => Err(LineStatusError::Io {
632                path: path.display().to_string(),
633                source,
634            }),
635        }
636    }
637
638    /// Load and re-verify the cached envelope for a line.
639    pub fn load(
640        &self,
641        line: &Line,
642        root_public_key: &[u8],
643    ) -> Result<Option<LineStatus>, LineStatusError> {
644        let path = self.envelope_path(line);
645        match std::fs::read(&path) {
646            Ok(bytes) => Ok(Some(LineStatus::verify_and_parse(&bytes, root_public_key)?)),
647            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
648            Err(source) => Err(LineStatusError::Io {
649                path: path.display().to_string(),
650                source,
651            }),
652        }
653    }
654
655    fn load_parsed(&self, line: &Line) -> Result<Option<LineStatus>, LineStatusError> {
656        let path = self.envelope_path(line);
657        match std::fs::read(&path) {
658            Ok(bytes) => {
659                // Cached envelopes were verified at update time; parse the
660                // payload without re-verifying just to read the counter.
661                let text = std::str::from_utf8(&bytes)
662                    .map_err(|_| LineStatusError::Payload("cache is not UTF-8".into()))?;
663                let env = wsc::dsse::DsseEnvelope::from_json(text)
664                    .map_err(|e| LineStatusError::Payload(e.to_string()))?;
665                let payload = env
666                    .payload_bytes()
667                    .map_err(|e| LineStatusError::Payload(e.to_string()))?;
668                Ok(Some(
669                    serde_json::from_slice(&payload)
670                        .map_err(|e| LineStatusError::Payload(e.to_string()))?,
671                ))
672            }
673            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
674            Err(source) => Err(LineStatusError::Io {
675                path: path.display().to_string(),
676                source,
677            }),
678        }
679    }
680
681    fn envelope_path(&self, line: &Line) -> PathBuf {
682        self.dir.join(format!("{line}.dsse.json"))
683    }
684}
685
686/// artifactType for a line-status envelope carried in an OCI image layout.
687pub const LINE_STATUS_ARTIFACT_TYPE: &str = LINE_STATUS_PAYLOAD_TYPE;
688/// Annotation naming the line a carried status document covers.
689pub const ANN_LINE: &str = "eu.pulseengine.varve.status-line";
690
691/// Attach a (verified-by-the-caller) status envelope to an existing OCI
692/// image layout — evidence added AFTER deposit, without touching any layer
693/// blob or digest. Replaces a previous document for the same line.
694pub fn attach_to_layout(
695    layout: &Path,
696    line: &Line,
697    envelope: &[u8],
698) -> Result<(), LineStatusError> {
699    let io = |path: &Path, source: std::io::Error| LineStatusError::Io {
700        path: path.display().to_string(),
701        source,
702    };
703    let digest = crate::store::manifest_digest(envelope);
704    let hex = digest.strip_prefix("sha256:").expect("digest shape");
705    let blob_dir = layout.join("blobs").join("sha256");
706    std::fs::create_dir_all(&blob_dir).map_err(|e| io(&blob_dir, e))?;
707    let blob_path = blob_dir.join(hex);
708    std::fs::write(&blob_path, envelope).map_err(|e| io(&blob_path, e))?;
709
710    let index_path = layout.join("index.json");
711    let mut index: serde_json::Value =
712        serde_json::from_slice(&std::fs::read(&index_path).map_err(|e| io(&index_path, e))?)
713            .map_err(|e| LineStatusError::Payload(format!("index.json: {e}")))?;
714    let entries = index["manifests"]
715        .as_array_mut()
716        .ok_or_else(|| LineStatusError::Payload("index.json has no manifests array".into()))?;
717    let line_name = line.to_string();
718    entries.retain(|e| {
719        !(e["artifactType"] == LINE_STATUS_ARTIFACT_TYPE
720            && e["annotations"][ANN_LINE] == *line_name)
721    });
722    entries.push(serde_json::json!({
723        "mediaType": "application/json",
724        "artifactType": LINE_STATUS_ARTIFACT_TYPE,
725        "digest": digest,
726        "size": envelope.len(),
727        "annotations": { ANN_LINE: line_name }
728    }));
729    std::fs::write(
730        &index_path,
731        serde_json::to_vec_pretty(&index).expect("index serializes"),
732    )
733    .map_err(|e| io(&index_path, e))?;
734    Ok(())
735}
736
737/// Fetch the baseline line-status a source carries beside a layer, verify it
738/// against the trust root, and cache it monotonically (REQ-STATUS-DIST-001).
739/// Returns `Ok(Some(counter))` when a baseline was cached, `Ok(None)` when the
740/// source carries none. Verification, cache, or transport failures are `Err`
741/// — the caller decides severity (the CLI downgrades them to a note, since a
742/// bad baseline never blocks an otherwise-verified install, but it is never
743/// silently cached). The untrusted bytes are re-verified here; the source is
744/// not trusted to have checked them.
745pub fn cache_baseline_from_source(
746    source: &dyn crate::source::LayerSource,
747    layer: &crate::source::LayerRef,
748    line: &Line,
749    root_pk: &[u8],
750    store_root: &Path,
751) -> Result<Option<u64>, LineStatusError> {
752    let envelope = match source
753        .fetch_line_status(layer)
754        .map_err(|e| LineStatusError::Payload(format!("fetching baseline line-status: {e}")))?
755    {
756        Some(bytes) => bytes,
757        None => return Ok(None),
758    };
759    let doc = LineStatus::verify_and_parse(&envelope, root_pk)?;
760    // A validly-signed status for a DIFFERENT line must not be cached under
761    // this one — mirror the `--from-file` guard so all cache paths agree.
762    if doc.line != line.to_string() {
763        return Err(LineStatusError::LineMismatch {
764            expected: line.to_string(),
765            got: doc.line,
766        });
767    }
768    let counter = doc.counter;
769    StatusCache::at_root(store_root).update(line, &envelope, &doc)?;
770    Ok(Some(counter))
771}
772
773/// Attach a signed line-status envelope to a deposit layout, deriving the
774/// line from the document itself (REQ-STATUS-DIST-001). Returns the line and
775/// counter attached. The payload is read to learn the line but not verified
776/// here — install re-verifies the bytes against the trust root, and the
777/// deposit pipeline produced this envelope moments earlier with its own key.
778pub fn attach_envelope_to_layout(
779    layout: &Path,
780    envelope: &[u8],
781) -> Result<(Line, u64), LineStatusError> {
782    let (line, counter, _) = attach_envelope_to_layout_checked(layout, envelope, false)?;
783    Ok((line, counter))
784}
785
786/// `attach_envelope_to_layout`, reporting which advisory check it was able to
787/// run and allowing the deliberate override (REQ-ADVISORY-002).
788pub fn attach_envelope_to_layout_checked(
789    layout: &Path,
790    envelope: &[u8],
791    force: bool,
792) -> Result<(Line, u64, RefCheck), LineStatusError> {
793    // Refuse a directory that is not a layout BEFORE writing anything into
794    // it: the old path created blobs/ inside an arbitrary directory and then
795    // failed on the missing index.json with a bare io error (varve#60).
796    if !layout.join("index.json").is_file() {
797        return Err(LineStatusError::NotALayout {
798            layout: layout.display().to_string(),
799        });
800    }
801    let text = std::str::from_utf8(envelope)
802        .map_err(|e| LineStatusError::Payload(format!("envelope is not utf-8: {e}")))?;
803    let env = wsc::dsse::DsseEnvelope::from_json(text).map_err(|_| not_an_envelope(text))?;
804    let payload = env
805        .payload_bytes()
806        .map_err(|e| LineStatusError::Payload(format!("envelope payload: {e}")))?;
807    let doc: LineStatus = serde_json::from_slice(&payload)
808        .map_err(|e| LineStatusError::Payload(format!("status document: {e}")))?;
809    let line: Line = doc
810        .line
811        .parse()
812        .map_err(|e| LineStatusError::Payload(format!("status line '{}': {e}", doc.line)))?;
813    // A yank or affected id outside the layout's line would attach fine and
814    // fire for nobody (varve#61) — this command knows the line, so it is the
815    // last producer-side place the typo is cheap to fix. And where the layout
816    // carries the realm's signed index, the ids can be checked against the
817    // layers that actually EXIST, not merely against the shape of a layer id
818    // (REQ-ADVISORY-002).
819    let check = doc.check_layer_refs_against(&known_layers_in_layout(layout, &doc.line), force)?;
820    // Monotonicity holds here too. `status --from-file` and `install` both
821    // refuse a counter regression; attaching did not, so a re-run CI step could
822    // silently downgrade a layout's baseline — shipping a pre-yank document
823    // that fresh consumers cache and are told "not yanked" about a YANKED
824    // layer. The one place the rule was missing was the one that produces the
825    // artifact.
826    if let Some(existing) = read_any_from_layout(layout)?
827        && let Ok(prev) = parse_unverified(&existing)
828        && prev.line == doc.line
829        && doc.counter < prev.counter
830    {
831        return Err(LineStatusError::Stale {
832            line: doc.line.clone(),
833            presented: doc.counter,
834            cached: prev.counter,
835        });
836    }
837    // The status must belong to THIS layout's line. Attaching a 2099.01 status
838    // to a 2026.08 layout used to succeed, leaving the consumer to discover it
839    // (REQ-PRODUCER-001).
840    if let Some(layout_line) = layout_line(layout)
841        && layout_line != line.to_string()
842    {
843        return Err(LineStatusError::LineMismatch {
844            expected: layout_line,
845            got: line.to_string(),
846        });
847    }
848    attach_to_layout(layout, &line, envelope)?;
849    Ok((line, doc.counter, check))
850}
851
852/// The bytes are not a DSSE envelope — with the commonest cause named: the
853/// raw status document handed over where the SIGNED envelope belongs. The
854/// verifier's own wrapping ("not a DSSE envelope: Internal error: [Failed to
855/// parse DSSE envelope: …]") said the same thing twice and the fix zero
856/// times (varve#60).
857fn not_an_envelope(text: &str) -> LineStatusError {
858    if serde_json::from_str::<LineStatus>(text).is_ok() {
859        LineStatusError::Payload(
860            "this is the UNSIGNED status document, not a signed envelope — sign it first \
861             (`varve sign-status --file <doc> --key <key> --out <envelope>`) and pass the \
862             envelope"
863                .into(),
864        )
865    } else {
866        LineStatusError::Payload(
867            "not a DSSE envelope — expected the signed output of `varve sign-status`".into(),
868        )
869    }
870}
871
872/// Parse a status document out of an envelope WITHOUT verifying it. Used only
873/// to read back what a layout already carries, so a regression can be refused;
874/// the signature is checked wherever the document is actually trusted.
875fn parse_unverified(envelope: &[u8]) -> Result<LineStatus, LineStatusError> {
876    let text = std::str::from_utf8(envelope)
877        .map_err(|e| LineStatusError::Payload(format!("envelope is not utf-8: {e}")))?;
878    let env = wsc::dsse::DsseEnvelope::from_json(text).map_err(|_| not_an_envelope(text))?;
879    let payload = env
880        .payload_bytes()
881        .map_err(|e| LineStatusError::Payload(format!("envelope payload: {e}")))?;
882    serde_json::from_slice(&payload)
883        .map_err(|e| LineStatusError::Payload(format!("status document: {e}")))
884}
885
886/// The line a deposit layout's own manifest declares, if it can be read. Best
887/// effort: a layout we cannot introspect is not blocked from being annotated,
888/// but one that plainly disagrees is.
889pub(crate) fn layout_line(layout: &Path) -> Option<String> {
890    let index: serde_json::Value =
891        serde_json::from_slice(&std::fs::read(layout.join("index.json")).ok()?).ok()?;
892    for m in index["manifests"].as_array()? {
893        let digest = m["digest"].as_str()?.replace(':', "-");
894        let blob = layout
895            .join("blobs")
896            .join("sha256")
897            .join(digest.trim_start_matches("sha256-"));
898        let Ok(bytes) = std::fs::read(&blob) else {
899            continue;
900        };
901        // The layer envelope's payload carries the line annotation. Reuse the
902        // DSSE reader already used in this module rather than hand-rolling.
903        let Ok(text) = std::str::from_utf8(&bytes) else {
904            continue;
905        };
906        let Ok(env) = wsc::dsse::DsseEnvelope::from_json(text) else {
907            continue;
908        };
909        let Ok(payload) = env.payload_bytes() else {
910            continue;
911        };
912        let Ok(doc) = serde_json::from_slice::<serde_json::Value>(&payload) else {
913            continue;
914        };
915        if let Some(line) = doc["annotations"]["eu.pulseengine.varve.line"].as_str() {
916            return Some(line.to_string());
917        }
918    }
919    None
920}
921
922/// Read the single baseline status envelope a deposit layout carries,
923/// without needing to name its line (REQ-STATUS-DIST-001). A deposit layout
924/// holds exactly one line-status; a consumer installing by digest may not
925/// know the line up front. Returns the first line-status referrer found.
926pub fn read_any_from_layout(layout: &Path) -> Result<Option<Vec<u8>>, LineStatusError> {
927    let index_path = layout.join("index.json");
928    let bytes = match std::fs::read(&index_path) {
929        Ok(bytes) => bytes,
930        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
931        Err(source) => {
932            return Err(LineStatusError::Io {
933                path: index_path.display().to_string(),
934                source,
935            });
936        }
937    };
938    let index: serde_json::Value = serde_json::from_slice(&bytes)
939        .map_err(|e| LineStatusError::Payload(format!("index.json: {e}")))?;
940    let Some(entry) = index["manifests"].as_array().and_then(|entries| {
941        entries
942            .iter()
943            .find(|e| e["artifactType"] == LINE_STATUS_ARTIFACT_TYPE)
944    }) else {
945        return Ok(None);
946    };
947    let digest = entry["digest"]
948        .as_str()
949        .ok_or_else(|| LineStatusError::Payload("status entry has no digest".into()))?;
950    let hex = digest.strip_prefix("sha256:").unwrap_or(digest);
951    let blob_path = layout.join("blobs").join("sha256").join(hex);
952    std::fs::read(&blob_path)
953        .map(Some)
954        .map_err(|source| LineStatusError::Io {
955            path: blob_path.display().to_string(),
956            source,
957        })
958}
959
960/// Read the status envelope for a line from an OCI image layout, if carried.
961pub fn read_from_layout(layout: &Path, line: &Line) -> Result<Option<Vec<u8>>, LineStatusError> {
962    let index_path = layout.join("index.json");
963    let bytes = match std::fs::read(&index_path) {
964        Ok(bytes) => bytes,
965        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
966        Err(source) => {
967            return Err(LineStatusError::Io {
968                path: index_path.display().to_string(),
969                source,
970            });
971        }
972    };
973    let index: serde_json::Value = serde_json::from_slice(&bytes)
974        .map_err(|e| LineStatusError::Payload(format!("index.json: {e}")))?;
975    let line_name = line.to_string();
976    let Some(entry) = index["manifests"].as_array().and_then(|entries| {
977        entries.iter().find(|e| {
978            e["artifactType"] == LINE_STATUS_ARTIFACT_TYPE
979                && e["annotations"][ANN_LINE] == *line_name
980        })
981    }) else {
982        return Ok(None);
983    };
984    let digest = entry["digest"]
985        .as_str()
986        .ok_or_else(|| LineStatusError::Payload("status entry has no digest".into()))?;
987    let hex = digest.strip_prefix("sha256:").unwrap_or(digest);
988    let blob_path = layout.join("blobs").join("sha256").join(hex);
989    std::fs::read(&blob_path)
990        .map(Some)
991        .map_err(|source| LineStatusError::Io {
992            path: blob_path.display().to_string(),
993            source,
994        })
995}
996
997#[cfg(test)]
998mod tests {
999    use super::*;
1000    use crate::verify::generate_root_keypair;
1001
1002    fn status(counter: u64) -> LineStatus {
1003        LineStatus {
1004            line: "2026.07".into(),
1005            counter,
1006            issued_at: "2026-08-07T00:00:00Z".into(),
1007            support_until: Some("2028-07-31".into()),
1008            yanked: BTreeMap::from([(
1009                "2026.07.0".to_string(),
1010                "CVE-2026-0001 in synth".to_string(),
1011            )]),
1012            known_problems: vec![
1013                KnownProblem {
1014                    id: "KP-1".into(),
1015                    title: "synth mla fusion regresses flat_flight".into(),
1016                    severity: "medium".into(),
1017                    affected: vec!["2026.07.0".into()],
1018                    workaround: Some("disable mla fusion".into()),
1019                    detection: None,
1020                    mitigation: None,
1021                },
1022                KnownProblem {
1023                    id: "KP-2".into(),
1024                    title: "witness truth-table gap on nested variants".into(),
1025                    severity: "high".into(),
1026                    affected: vec!["2026.07.0".into(), "2026.07.1".into()],
1027                    workaround: None,
1028                    detection: Some("witness gap rows non-empty".into()),
1029                    mitigation: None,
1030                },
1031            ],
1032        }
1033    }
1034
1035    // rivet: verifies REQ-KP-001
1036    #[test]
1037    fn a_signed_status_document_round_trips() {
1038        let (sk, pk) = generate_root_keypair();
1039        let envelope = status(1).sign(&sk, "varve-root-1").unwrap();
1040        let parsed = LineStatus::verify_and_parse(envelope.as_bytes(), &pk).unwrap();
1041        assert_eq!(parsed, status(1));
1042    }
1043
1044    // rivet: verifies REQ-KP-001
1045    #[test]
1046    fn a_layer_manifest_envelope_cannot_pose_as_a_status_document() {
1047        let (sk, pk) = generate_root_keypair();
1048        // Signed with the right key but the wrong payload type.
1049        let manifest = crate::manifest::fixtures::manifest(
1050            "2026.07.0",
1051            "qualified",
1052            1,
1053            "2026-08-07T00:00:00Z",
1054        );
1055        let envelope = crate::verify::sign_layer_manifest(&manifest, &sk, "varve-root-1").unwrap();
1056        let err = LineStatus::verify_and_parse(envelope.as_bytes(), &pk).unwrap_err();
1057        assert!(err.to_string().contains("payload type"), "got: {err}");
1058    }
1059
1060    // rivet: verifies REQ-KP-001
1061    #[test]
1062    fn the_report_names_yank_support_window_and_problem_counts() {
1063        let doc = status(1);
1064        let report = doc.report_for(&"2026.07.0".parse().unwrap());
1065        assert_eq!(
1066            report.yanked_reason.as_deref(),
1067            Some("CVE-2026-0001 in synth")
1068        );
1069        assert_eq!(report.support_until.as_deref(), Some("2028-07-31"));
1070        assert_eq!(report.problems_total, 2);
1071        assert_eq!(report.problems_with_workaround, 1);
1072        let clean = doc.report_for(&"2026.07.2".parse().unwrap());
1073        assert_eq!(clean.yanked_reason, None);
1074        assert_eq!(clean.problems_total, 0);
1075    }
1076
1077    // rivet: verifies REQ-KP-001
1078    #[test]
1079    fn attaching_status_to_a_layout_leaves_every_layer_blob_untouched() {
1080        use crate::deposit::{DepositSpec, DepositTool, deposit};
1081        let (sk, pk) = generate_root_keypair();
1082        let tmp = tempfile::tempdir().unwrap();
1083        let dest = tmp.path().join("layout");
1084        let spec = DepositSpec {
1085            includes: Vec::new(),
1086            layer: "2026.07.0".parse().unwrap(),
1087            channel: "qualified".into(),
1088            counter: 1,
1089            issued_at: "2026-08-07T00:00:00Z".into(),
1090            tools: vec![DepositTool {
1091                name: "synth".into(),
1092                version: "1".into(),
1093                platform: None,
1094                bytes: b"t".to_vec(),
1095                source: None,
1096                runner: None,
1097                kind: None,
1098                sdk_prefix: None,
1099            }],
1100        };
1101        let outcome = deposit(&spec, &sk, "k", &dest).unwrap();
1102
1103        // Snapshot the layer-relevant blobs before attaching evidence.
1104        let blob_dir = dest.join("blobs/sha256");
1105        let before: std::collections::BTreeMap<String, Vec<u8>> = std::fs::read_dir(&blob_dir)
1106            .unwrap()
1107            .map(|e| {
1108                let p = e.unwrap().path();
1109                (
1110                    p.file_name().unwrap().to_string_lossy().into_owned(),
1111                    std::fs::read(&p).unwrap(),
1112                )
1113            })
1114            .collect();
1115
1116        let line: Line = "2026.07.0".parse::<LayerId>().unwrap().line().clone();
1117        let envelope = status(1).sign(&sk, "k").unwrap();
1118        attach_to_layout(&dest, &line, envelope.as_bytes()).unwrap();
1119
1120        // Every pre-existing blob is byte-identical; the manifest digest is
1121        // unchanged — evidence was added, identity was not.
1122        for (name, bytes) in &before {
1123            assert_eq!(&std::fs::read(blob_dir.join(name)).unwrap(), bytes);
1124        }
1125        let carried = read_from_layout(&dest, &line).unwrap().unwrap();
1126        let parsed = LineStatus::verify_and_parse(&carried, &pk).unwrap();
1127        assert_eq!(parsed.counter, 1);
1128        let hex = outcome.digest.strip_prefix("sha256:").unwrap();
1129        assert!(
1130            blob_dir.join(hex).is_file(),
1131            "layer manifest blob still present"
1132        );
1133
1134        // Replacing the document for the same line keeps exactly one entry.
1135        let envelope2 = status(2).sign(&sk, "k").unwrap();
1136        attach_to_layout(&dest, &line, envelope2.as_bytes()).unwrap();
1137        let index: serde_json::Value =
1138            serde_json::from_slice(&std::fs::read(dest.join("index.json")).unwrap()).unwrap();
1139        let count = index["manifests"]
1140            .as_array()
1141            .unwrap()
1142            .iter()
1143            .filter(|e| e["artifactType"] == LINE_STATUS_ARTIFACT_TYPE)
1144            .count();
1145        assert_eq!(count, 1);
1146    }
1147
1148    // rivet: verifies REQ-KP-001
1149    #[test]
1150    fn the_cache_refuses_a_counter_regression() {
1151        let (sk, pk) = generate_root_keypair();
1152        let tmp = tempfile::tempdir().unwrap();
1153        let cache = StatusCache::at_root(tmp.path());
1154        let line: Line = "2026.07.0".parse::<LayerId>().unwrap().line().clone();
1155
1156        let newer = status(2);
1157        let env2 = newer.sign(&sk, "k").unwrap();
1158        cache.update(&line, env2.as_bytes(), &newer).unwrap();
1159
1160        let older = status(1);
1161        let env1 = older.sign(&sk, "k").unwrap();
1162        let err = cache.update(&line, env1.as_bytes(), &older).unwrap_err();
1163        assert!(matches!(
1164            err,
1165            LineStatusError::Stale {
1166                presented: 1,
1167                cached: 2,
1168                ..
1169            }
1170        ));
1171
1172        // The cached newer document survives and re-verifies.
1173        let loaded = cache.load(&line, &pk).unwrap().unwrap();
1174        assert_eq!(loaded.counter, 2);
1175    }
1176
1177    // rivet: verifies REQ-STATUS-DIST-001
1178    #[test]
1179    fn a_source_baseline_is_verified_and_cached_so_status_works_offline() {
1180        use crate::source::{LayerRef, MemorySource};
1181        let (sk, pk) = generate_root_keypair();
1182        let tmp = tempfile::tempdir().unwrap();
1183        let store_root = tmp.path();
1184        let line: Line = "2026.07.0".parse::<LayerId>().unwrap().line().clone();
1185        let doc = status(5);
1186        let envelope = doc.sign(&sk, "k").unwrap();
1187        let source = MemorySource::new().with_line_status(envelope.as_bytes());
1188        let layer = LayerRef::Name("2026.07.0".parse().unwrap());
1189
1190        let cached = cache_baseline_from_source(&source, &layer, &line, &pk, store_root).unwrap();
1191        assert_eq!(
1192            cached,
1193            Some(5),
1194            "a carried baseline is cached at its counter"
1195        );
1196
1197        // Now `status` works offline: the cache has it, verified.
1198        let loaded = StatusCache::at_root(store_root)
1199            .load(&line, &pk)
1200            .unwrap()
1201            .unwrap();
1202        assert_eq!(loaded.counter, 5);
1203    }
1204
1205    // rivet: verifies REQ-STATUS-DIST-001, REQ-VERIFY-001
1206    #[test]
1207    fn a_baseline_for_the_wrong_line_is_refused_not_miscached() {
1208        // A root-signed status document for a DIFFERENT line must not be
1209        // cached under the requested line — even validly signed. (Clean-room
1210        // review finding: the --from-file path asserted this; the baseline
1211        // path did not.)
1212        use crate::source::{LayerRef, MemorySource};
1213        let (sk, pk) = generate_root_keypair();
1214        let tmp = tempfile::tempdir().unwrap();
1215        let requested: Line = "2026.07.0".parse::<LayerId>().unwrap().line().clone();
1216        // A self-consistent document for the WRONG line — internally valid,
1217        // validly signed, and still not the line this consumer asked about.
1218        let doc = LineStatus {
1219            line: "2026.08".into(),
1220            counter: 5,
1221            issued_at: "2026-08-07T00:00:00Z".into(),
1222            support_until: None,
1223            yanked: BTreeMap::new(),
1224            known_problems: Vec::new(),
1225        };
1226        let envelope = doc.sign(&sk, "k").unwrap();
1227        let source = MemorySource::new().with_line_status(envelope.as_bytes());
1228        let err = cache_baseline_from_source(
1229            &source,
1230            &LayerRef::Name("2026.07.0".parse().unwrap()),
1231            &requested,
1232            &pk,
1233            tmp.path(),
1234        )
1235        .unwrap_err();
1236        assert!(
1237            matches!(err, LineStatusError::LineMismatch { .. }),
1238            "a baseline for the wrong line must be refused: {err}"
1239        );
1240        assert!(
1241            StatusCache::at_root(tmp.path())
1242                .load(&requested, &pk)
1243                .unwrap()
1244                .is_none(),
1245            "nothing is cached under the requested line"
1246        );
1247    }
1248
1249    // rivet: verifies REQ-STATUS-DIST-001
1250    #[test]
1251    fn a_source_with_no_baseline_caches_nothing_and_does_not_error() {
1252        use crate::source::{LayerRef, MemorySource};
1253        let (_sk, pk) = generate_root_keypair();
1254        let tmp = tempfile::tempdir().unwrap();
1255        let line: Line = "2026.07.0".parse::<LayerId>().unwrap().line().clone();
1256        let source = MemorySource::new();
1257        let cached = cache_baseline_from_source(
1258            &source,
1259            &LayerRef::Name("2026.07.0".parse().unwrap()),
1260            &line,
1261            &pk,
1262            tmp.path(),
1263        )
1264        .unwrap();
1265        assert_eq!(cached, None);
1266    }
1267
1268    // rivet: verifies REQ-STATUS-DIST-001, REQ-VERIFY-001
1269    #[test]
1270    fn a_baseline_signed_by_an_impostor_is_refused_not_cached() {
1271        use crate::source::{LayerRef, MemorySource};
1272        let (attacker_sk, _) = generate_root_keypair();
1273        let (_real_sk, real_pk) = generate_root_keypair();
1274        let tmp = tempfile::tempdir().unwrap();
1275        let line: Line = "2026.07.0".parse::<LayerId>().unwrap().line().clone();
1276        let envelope = status(5).sign(&attacker_sk, "k").unwrap();
1277        let source = MemorySource::new().with_line_status(envelope.as_bytes());
1278        let err = cache_baseline_from_source(
1279            &source,
1280            &LayerRef::Name("2026.07.0".parse().unwrap()),
1281            &line,
1282            &real_pk,
1283            tmp.path(),
1284        )
1285        .unwrap_err();
1286        // The impostor's baseline never reaches the cache.
1287        assert!(
1288            StatusCache::at_root(tmp.path())
1289                .load(&line, &real_pk)
1290                .unwrap()
1291                .is_none(),
1292            "a baseline that fails verification must not be cached: {err}"
1293        );
1294    }
1295
1296    // rivet: verifies REQ-STATUS-DIST-001
1297    #[test]
1298    fn attaching_by_envelope_derives_the_line_from_the_document() {
1299        use crate::deposit::{DepositSpec, DepositTool, deposit};
1300        let (sk, pk) = generate_root_keypair();
1301        let tmp = tempfile::tempdir().unwrap();
1302        let dest = tmp.path().join("layout");
1303        deposit(
1304            &DepositSpec {
1305                includes: Vec::new(),
1306                layer: "2026.07.0".parse().unwrap(),
1307                channel: "qualified".into(),
1308                counter: 1,
1309                issued_at: "2026-08-07T00:00:00Z".into(),
1310                tools: vec![DepositTool {
1311                    name: "synth".into(),
1312                    version: "1".into(),
1313                    platform: None,
1314                    bytes: b"t".to_vec(),
1315                    source: None,
1316                    runner: None,
1317                    kind: None,
1318                    sdk_prefix: None,
1319                }],
1320            },
1321            &sk,
1322            "k",
1323            &dest,
1324        )
1325        .unwrap();
1326
1327        let envelope = status(4).sign(&sk, "k").unwrap();
1328        let (line, counter) = attach_envelope_to_layout(&dest, envelope.as_bytes()).unwrap();
1329        assert_eq!(line.to_string(), "2026.07");
1330        assert_eq!(counter, 4);
1331        // The layout now carries it and it re-verifies.
1332        let carried = read_any_from_layout(&dest).unwrap().unwrap();
1333        assert_eq!(
1334            LineStatus::verify_and_parse(&carried, &pk).unwrap().counter,
1335            4
1336        );
1337    }
1338
1339    // rivet: verifies REQ-PRODUCE-002
1340    #[test]
1341    fn attaching_a_stale_document_over_a_newer_one_is_refused() {
1342        // An independent review deleted the Stale block from
1343        // attach_envelope_to_layout and the whole workspace stayed green: the
1344        // test cited as this clause's evidence exercises StatusCache::update, a
1345        // DIFFERENT function, and the attach test above attaches exactly once.
1346        // Unguarded, a re-run CI step downgrades a layout's baseline — shipping
1347        // a pre-yank document that fresh consumers cache and are told "not
1348        // yanked" about a YANKED layer.
1349        use crate::deposit::{DepositSpec, DepositTool, deposit};
1350        let (sk, _pk) = generate_root_keypair();
1351        let tmp = tempfile::tempdir().unwrap();
1352        let dest = tmp.path().join("layout");
1353        deposit(
1354            &DepositSpec {
1355                includes: Vec::new(),
1356                layer: "2026.07.0".parse().unwrap(),
1357                channel: "qualified".into(),
1358                counter: 1,
1359                issued_at: "2026-08-07T00:00:00Z".into(),
1360                tools: vec![DepositTool {
1361                    name: "synth".into(),
1362                    version: "1".into(),
1363                    platform: None,
1364                    bytes: b"t".to_vec(),
1365                    source: None,
1366                    runner: None,
1367                    kind: None,
1368                    sdk_prefix: None,
1369                }],
1370            },
1371            &sk,
1372            "k",
1373            &dest,
1374        )
1375        .unwrap();
1376
1377        // The newer document lands…
1378        attach_envelope_to_layout(&dest, status(7).sign(&sk, "k").unwrap().as_bytes()).unwrap();
1379        // …and the older one is refused, naming both counters.
1380        let err = attach_envelope_to_layout(&dest, status(3).sign(&sk, "k").unwrap().as_bytes())
1381            .unwrap_err();
1382        assert!(
1383            matches!(
1384                err,
1385                LineStatusError::Stale {
1386                    presented: 3,
1387                    cached: 7,
1388                    ..
1389                }
1390            ),
1391            "a lower counter must be refused, got {err}"
1392        );
1393        let msg = err.to_string();
1394        assert!(msg.contains('3') && msg.contains('7'), "names both: {msg}");
1395        // The layout still carries the NEWER document, not the stale one.
1396        let carried = parse_unverified(&read_any_from_layout(&dest).unwrap().unwrap()).unwrap();
1397        assert_eq!(
1398            carried.counter, 7,
1399            "the newer baseline survives the attempt"
1400        );
1401        // Re-attaching the SAME counter is not a regression and is allowed —
1402        // CI re-runs must stay idempotent.
1403        attach_envelope_to_layout(&dest, status(7).sign(&sk, "k").unwrap().as_bytes()).unwrap();
1404    }
1405
1406    // rivet: verifies REQ-PRODUCE-002
1407    #[test]
1408    fn an_advisory_that_could_never_fire_is_refused_at_sign_time() {
1409        // varve#61: `report_for` matches ids EXACTLY and the cache is keyed
1410        // per line, so a typo'd affected id — "2026.9.0" for "2026.09.0" —
1411        // signed fine and the advisory then fired for nobody. The signature
1412        // is the cheapest place to stop it.
1413        let (sk, _pk) = generate_root_keypair();
1414        let cases: &[(&str, &str)] = &[
1415            ("2026.7.0", "not a valid YYYY.MM.P id"), // typo'd month width
1416            ("2026.07", "missing its patch component"), // a line, not a layer
1417            ("2026.08.0", "belongs to another line"), // wrong line entirely
1418            ("2026.07.O", "letter O for zero"),
1419        ];
1420        for (bad, why) in cases {
1421            let mut doc = status(1);
1422            doc.known_problems[0].affected = vec![bad.to_string()];
1423            let err = doc.sign(&sk, "k").unwrap_err();
1424            assert!(
1425                matches!(err, LineStatusError::DeadReference { .. }),
1426                "{why}: affected id {bad:?} must be refused, got: {err}"
1427            );
1428            let msg = err.to_string();
1429            assert!(
1430                msg.contains(bad) && msg.contains("2026.07") && msg.contains("re-sign"),
1431                "the error must name the id, the line, and the fix: {msg}"
1432            );
1433        }
1434        // A typo'd YANK key is the same dead advisory.
1435        let mut doc = status(1);
1436        doc.yanked = BTreeMap::from([("2026.8.0".to_string(), "CVE".to_string())]);
1437        assert!(matches!(
1438            doc.sign(&sk, "k").unwrap_err(),
1439            LineStatusError::DeadReference { .. }
1440        ));
1441        // …and the untouched fixture still signs: the gate can pass, not
1442        // merely fail.
1443        status(1).sign(&sk, "k").unwrap();
1444    }
1445
1446    // rivet: verifies REQ-PRODUCE-002
1447    #[test]
1448    fn attach_refuses_a_pre_signed_advisory_that_could_never_fire() {
1449        // The envelope may come from an older varve whose sign-status did not
1450        // validate — attach is the last producer-side gate before the layout
1451        // ships. Built with the raw signer to bypass `sign`'s own check,
1452        // exactly as an old binary would have.
1453        use crate::deposit::{DepositSpec, DepositTool, deposit};
1454        let (sk, _pk) = generate_root_keypair();
1455        let tmp = tempfile::tempdir().unwrap();
1456        let dest = tmp.path().join("layout");
1457        deposit(
1458            &DepositSpec {
1459                includes: Vec::new(),
1460                layer: "2026.07.0".parse().unwrap(),
1461                channel: "qualified".into(),
1462                counter: 1,
1463                issued_at: "2026-08-07T00:00:00Z".into(),
1464                tools: vec![DepositTool {
1465                    name: "synth".into(),
1466                    version: "1".into(),
1467                    platform: None,
1468                    bytes: b"t".to_vec(),
1469                    source: None,
1470                    runner: None,
1471                    kind: None,
1472                    sdk_prefix: None,
1473                }],
1474            },
1475            &sk,
1476            "k",
1477            &dest,
1478        )
1479        .unwrap();
1480        let mut doc = status(1);
1481        doc.known_problems[0].affected = vec!["2026.7.0".to_string()];
1482        let payload = serde_json::to_vec_pretty(&doc).unwrap();
1483        let envelope = dsse_sign_typed(&payload, LINE_STATUS_PAYLOAD_TYPE, &sk, "k").unwrap();
1484        let err = attach_envelope_to_layout(&dest, envelope.as_bytes()).unwrap_err();
1485        assert!(
1486            matches!(err, LineStatusError::DeadReference { .. }),
1487            "got: {err}"
1488        );
1489        assert!(
1490            read_any_from_layout(&dest).unwrap().is_none(),
1491            "the dead advisory must not land in the layout"
1492        );
1493    }
1494
1495    // rivet: verifies REQ-PRODUCE-002
1496    #[test]
1497    fn attaching_to_a_directory_that_is_not_a_layout_is_refused_before_writing() {
1498        // The old path created blobs/sha256/ inside the directory and then
1499        // failed on index.json with "io error at …: No such file or directory
1500        // (os error 2): No such file or directory (os error 2)" — the cause
1501        // twice, the fix never (varve#60).
1502        let (sk, _pk) = generate_root_keypair();
1503        let tmp = tempfile::tempdir().unwrap();
1504        let not_a_layout = tmp.path().join("somedir");
1505        std::fs::create_dir_all(&not_a_layout).unwrap();
1506        let envelope = status(1).sign(&sk, "k").unwrap();
1507        let err = attach_envelope_to_layout(&not_a_layout, envelope.as_bytes()).unwrap_err();
1508        assert!(
1509            matches!(err, LineStatusError::NotALayout { .. }),
1510            "got: {err}"
1511        );
1512        assert!(
1513            err.to_string().contains("varve deposit"),
1514            "the error must carry its fix: {err}"
1515        );
1516        assert!(
1517            !not_a_layout.join("blobs").exists(),
1518            "nothing may be written into a directory that is not a layout"
1519        );
1520    }
1521
1522    // rivet: verifies REQ-PRODUCE-002
1523    #[test]
1524    fn the_unsigned_document_mistake_is_named_not_wrapped() {
1525        // Handing the raw status JSON where the signed envelope belongs is
1526        // the commonest producer mistake; the old error was a doubled parser
1527        // wrap that never said "sign it" (varve#60).
1528        let raw = serde_json::to_string_pretty(&status(1)).unwrap();
1529        let err = not_an_envelope(&raw);
1530        let msg = err.to_string();
1531        assert!(
1532            msg.contains("UNSIGNED") && msg.contains("varve sign-status"),
1533            "raw document must be diagnosed with its fix: {msg}"
1534        );
1535        // Garbage is still garbage, said once, with the expected shape named.
1536        let msg = not_an_envelope("garbage").to_string();
1537        assert!(
1538            msg.contains("not a DSSE envelope") && msg.contains("varve sign-status"),
1539            "got: {msg}"
1540        );
1541        // And verify_and_parse routes through the same diagnosis.
1542        let (_sk, pk) = generate_root_keypair();
1543        let err = LineStatus::verify_and_parse(raw.as_bytes(), &pk).unwrap_err();
1544        assert!(err.to_string().contains("UNSIGNED"), "got: {err}");
1545    }
1546
1547    // rivet: verifies REQ-STATUS-DIST-001
1548    #[test]
1549    fn a_deposit_layouts_baseline_is_readable_without_naming_the_line() {
1550        // A registry/layout consumer that installs by digest may not know the
1551        // line up front — the baseline must be recoverable from the layout
1552        // alone. A deposit layout carries exactly one line-status.
1553        use crate::deposit::{DepositSpec, DepositTool, deposit};
1554        let (sk, pk) = generate_root_keypair();
1555        let tmp = tempfile::tempdir().unwrap();
1556        let dest = tmp.path().join("layout");
1557        let spec = DepositSpec {
1558            includes: Vec::new(),
1559            layer: "2026.07.0".parse().unwrap(),
1560            channel: "qualified".into(),
1561            counter: 1,
1562            issued_at: "2026-08-07T00:00:00Z".into(),
1563            tools: vec![DepositTool {
1564                name: "synth".into(),
1565                version: "1".into(),
1566                platform: None,
1567                bytes: b"t".to_vec(),
1568                source: None,
1569                runner: None,
1570                kind: None,
1571                sdk_prefix: None,
1572            }],
1573        };
1574        deposit(&spec, &sk, "k", &dest).unwrap();
1575
1576        // No baseline yet -> None, not an error.
1577        assert!(read_any_from_layout(&dest).unwrap().is_none());
1578
1579        let line: Line = "2026.07.0".parse::<LayerId>().unwrap().line().clone();
1580        let envelope = status(3).sign(&sk, "k").unwrap();
1581        attach_to_layout(&dest, &line, envelope.as_bytes()).unwrap();
1582
1583        let carried = read_any_from_layout(&dest).unwrap().unwrap();
1584        let parsed = LineStatus::verify_and_parse(&carried, &pk).unwrap();
1585        assert_eq!(parsed.counter, 3);
1586    }
1587
1588    // ───────────────────────── REQ-ADVISORY-002 ─────────────────────────
1589
1590    /// A listing naming exactly the layers of the July line the fixture
1591    /// document talks about.
1592    fn listing(layers: &[&str]) -> KnownLayers {
1593        KnownLayers::Known {
1594            source: "the signed line-index for 2026.07 (counter 1)".into(),
1595            line: Some("2026.07".into()),
1596            layers: layers.iter().map(|s| s.to_string()).collect(),
1597        }
1598    }
1599
1600    // rivet: verifies REQ-ADVISORY-002
1601    #[test]
1602    fn an_affected_id_naming_no_existing_layer_is_refused_and_the_verdict_lists_what_does_exist() {
1603        // The defect: one wrong character. `2026.07.10` is a well-formed layer
1604        // id of the right line, so every shape check passes; it names no layer
1605        // that exists, so `varve status` — which matches ids EXACTLY — never
1606        // fires it. The producer sees success, the consumer sees nothing.
1607        let mut doc = status(1);
1608        doc.yanked.clear();
1609        doc.known_problems = vec![KnownProblem {
1610            id: "KP-1".into(),
1611            title: "t".into(),
1612            severity: "high".into(),
1613            affected: vec!["2026.07.10".into()],
1614            workaround: None,
1615            detection: None,
1616            mitigation: None,
1617        }];
1618        let err = doc
1619            .check_layer_refs_against(&listing(&["2026.07.0", "2026.07.1"]), false)
1620            .expect_err("an advisory that can never fire must be refused");
1621        assert!(
1622            matches!(&err, LineStatusError::UnknownLayer { id, .. } if id == "2026.07.10"),
1623            "got: {err}"
1624        );
1625        let msg = err.to_string();
1626        assert!(msg.contains("KP-1"), "name the entry at fault: {msg}");
1627        // The ids that DO exist — the shape varve already uses for tools
1628        // ("it exposes: …"). A refusal that does not show the alternatives
1629        // sends the operator back to the registry to guess.
1630        assert!(
1631            msg.contains("2026.07.0") && msg.contains("2026.07.1"),
1632            "the refusal must list the ids that exist: {msg}"
1633        );
1634        assert!(msg.contains("--force"), "{msg}");
1635    }
1636
1637    // rivet: verifies REQ-ADVISORY-002
1638    #[test]
1639    fn a_yank_key_is_checked_against_existing_layers_too_not_only_affected() {
1640        // A yank is the entry with the most consequence and the least
1641        // redundancy: nothing else in the document repeats it, so a typo'd
1642        // yank key is a withdrawal that silently never happened.
1643        let mut doc = status(1);
1644        doc.known_problems.clear();
1645        doc.yanked = BTreeMap::from([("2026.07.9".to_string(), "CVE".to_string())]);
1646        let err = doc
1647            .check_layer_refs_against(&listing(&["2026.07.0"]), false)
1648            .expect_err("a yank naming no layer must be refused");
1649        assert!(
1650            matches!(&err, LineStatusError::UnknownLayer { what, id, .. }
1651                     if what.contains("yank") && id == "2026.07.9"),
1652            "got: {err}"
1653        );
1654    }
1655
1656    // rivet: verifies REQ-ADVISORY-002
1657    #[test]
1658    fn a_document_whose_ids_all_exist_passes_and_says_what_was_checked() {
1659        // The other half of the rule: the check must be capable of PASSING, or
1660        // it is not a check, it is a ban on advisories.
1661        let check = status(1)
1662            .check_layer_refs_against(&listing(&["2026.07.0", "2026.07.1"]), false)
1663            .expect("every id in the fixture exists on the line");
1664        assert!(check.existence_checked);
1665        assert!(
1666            check.note.contains("checked against") && check.note.contains("2 layers"),
1667            "the note must state the check that RAN: {}",
1668            check.note
1669        );
1670    }
1671
1672    // rivet: verifies REQ-ADVISORY-002
1673    #[test]
1674    fn where_the_line_is_not_visible_the_answer_says_which_check_was_not_run() {
1675        // "Silence must not." Where no listing is in reach the existence check
1676        // cannot run, and a bare "signed" would imply a completeness that was
1677        // never established — the exact shape of the defect, moved one level
1678        // up into the tool's own reporting.
1679        let check = status(1)
1680            .check_layer_refs_against(&KnownLayers::unknown("no line-index was supplied"), false)
1681            .unwrap();
1682        assert!(
1683            !check.existence_checked,
1684            "an unchecked document must not report itself as checked"
1685        );
1686        assert!(
1687            check.note.contains("NOT") && check.note.contains("no line-index was supplied"),
1688            "the note must name the check that did NOT run, and why: {}",
1689            check.note
1690        );
1691    }
1692
1693    // rivet: verifies REQ-ADVISORY-002
1694    #[test]
1695    fn force_allows_a_layer_not_deposited_yet_but_never_a_malformed_id() {
1696        // `--force` exists for one legitimate case: pre-signing an advisory
1697        // for a layer about to be deposited. It must not become a way past the
1698        // SHAPE check — an id that is not a layer identifier of this line
1699        // cannot become correct later, so there is nothing for force to allow.
1700        let mut doc = status(1);
1701        doc.yanked.clear();
1702        doc.known_problems = vec![KnownProblem {
1703            id: "KP-1".into(),
1704            title: "t".into(),
1705            severity: "high".into(),
1706            affected: vec!["2026.07.9".into()],
1707            workaround: None,
1708            detection: None,
1709            mitigation: None,
1710        }];
1711        let check = doc
1712            .check_layer_refs_against(&listing(&["2026.07.0"]), true)
1713            .expect("--force pre-signs for a layer not deposited yet");
1714        assert!(
1715            !check.existence_checked,
1716            "forcing must not report the check as having run"
1717        );
1718        assert!(check.note.contains("--force"), "{}", check.note);
1719
1720        // …and the same document with a typo that is not a layer id at all is
1721        // still refused, force or no force.
1722        for id in ["2026.07", "twenty-twenty-six", "2026.08.0"] {
1723            doc.known_problems[0].affected = vec![id.to_string()];
1724            match doc.check_layer_refs_against(&listing(&["2026.07.0"]), true) {
1725                Err(LineStatusError::DeadReference { .. }) => {}
1726                other => panic!("'{id}' must be refused even under --force, got: {other:?}"),
1727            }
1728        }
1729    }
1730
1731    // rivet: verifies REQ-ADVISORY-002
1732    #[test]
1733    fn a_listing_for_another_line_is_refused_rather_than_used() {
1734        // A listing for a different line answers a different question. Used
1735        // anyway it would either wave everything through or refuse everything,
1736        // and both verdicts would be reported as if they meant something.
1737        let wrong = KnownLayers::Known {
1738            source: "the signed line-index for 2026.08".into(),
1739            line: Some("2026.08".into()),
1740            layers: vec!["2026.08.0".into()],
1741        };
1742        let err = status(1)
1743            .check_layer_refs_against(&wrong, false)
1744            .expect_err("a listing for another line must not be used as this line's");
1745        assert!(
1746            matches!(&err, LineStatusError::LineMismatch { expected, got }
1747                     if expected == "2026.07" && got == "2026.08"),
1748            "got: {err}"
1749        );
1750    }
1751
1752    // rivet: verifies REQ-ADVISORY-002
1753    #[test]
1754    fn a_layout_becomes_a_listing_only_once_the_signed_index_is_attached() {
1755        // Where the signer CAN see the line's layers, and where it cannot. A
1756        // deposit layout holds ONE layer — it is not a listing of the line, and
1757        // treating it as one would refuse advisories about layers that exist
1758        // perfectly well elsewhere. The realm's signed index IS a listing.
1759        use crate::deposit::{DepositSpec, DepositTool, deposit};
1760        let (sk, _pk) = generate_root_keypair();
1761        let tmp = tempfile::tempdir().unwrap();
1762        let dest = tmp.path().join("layout");
1763        deposit(
1764            &DepositSpec {
1765                includes: Vec::new(),
1766                layer: "2026.07.0".parse().unwrap(),
1767                channel: "qualified".into(),
1768                counter: 1,
1769                issued_at: "2026-08-07T00:00:00Z".into(),
1770                tools: vec![DepositTool {
1771                    name: "synth".into(),
1772                    version: "1".into(),
1773                    platform: None,
1774                    bytes: b"t".to_vec(),
1775                    source: None,
1776                    runner: None,
1777                    kind: None,
1778                    sdk_prefix: None,
1779                }],
1780            },
1781            &sk,
1782            "k",
1783            &dest,
1784        )
1785        .unwrap();
1786
1787        // No index yet: NOT a listing, and it says why rather than pretending.
1788        let known = known_layers_in_layout(&dest, "2026.07");
1789        assert!(
1790            matches!(&known, KnownLayers::Unknown { why } if why.contains("not a listing")),
1791            "got: {known:?}"
1792        );
1793
1794        let index = crate::lineindex::LineIndex {
1795            line: "2026.07".into(),
1796            counter: 1,
1797            issued_at: "2026-08-07T00:00:00Z".into(),
1798            layers: vec![crate::lineindex::IndexedLayer {
1799                layer: "2026.07.0".into(),
1800                digest: "sha256:aa".into(),
1801                channel: "qualified".into(),
1802                counter: 1,
1803            }],
1804        };
1805        crate::lineindex::attach_to_layout(
1806            &dest,
1807            "2026.07",
1808            index.sign(&sk, "k").unwrap().as_bytes(),
1809        )
1810        .unwrap();
1811
1812        let known = known_layers_in_layout(&dest, "2026.07");
1813        assert_eq!(
1814            known,
1815            KnownLayers::Known {
1816                source: "the signed line-index for 2026.07 (counter 1)".into(),
1817                line: Some("2026.07".into()),
1818                layers: vec!["2026.07.0".into()],
1819            }
1820        );
1821
1822        // …and the attach seam now refuses the advisory that could never fire,
1823        // while the same document naming the real layer attaches and reports
1824        // the check it ran.
1825        let mut doc = status(2);
1826        doc.yanked.clear();
1827        doc.known_problems = vec![KnownProblem {
1828            id: "KP-1".into(),
1829            title: "t".into(),
1830            severity: "high".into(),
1831            affected: vec!["2026.07.1".into()],
1832            workaround: None,
1833            detection: None,
1834            mitigation: None,
1835        }];
1836        let err = attach_envelope_to_layout(&dest, doc.sign(&sk, "k").unwrap().as_bytes())
1837            .expect_err("2026.07.1 is not on this line's index");
1838        assert!(
1839            matches!(&err, LineStatusError::UnknownLayer { .. }),
1840            "got: {err}"
1841        );
1842
1843        doc.known_problems[0].affected = vec!["2026.07.0".into()];
1844        let (_line, counter, check) =
1845            attach_envelope_to_layout_checked(&dest, doc.sign(&sk, "k").unwrap().as_bytes(), false)
1846                .unwrap();
1847        assert_eq!(counter, 2);
1848        assert!(check.existence_checked, "{}", check.note);
1849    }
1850
1851    // rivet: verifies REQ-ADVISORY-002
1852    #[test]
1853    fn signing_reports_the_check_it_ran_alongside_the_envelope() {
1854        // The producing seam. `sign_against` hands back the envelope AND what
1855        // was verified about it, so the CLI can print the check rather than a
1856        // bare "signed" that implies a complete one.
1857        let (sk, pk) = generate_root_keypair();
1858        let (envelope, check) = status(1)
1859            .sign_against(&listing(&["2026.07.0", "2026.07.1"]), false, &sk, "k")
1860            .unwrap();
1861        assert!(check.existence_checked);
1862        assert_eq!(
1863            LineStatus::verify_and_parse(envelope.as_bytes(), &pk).unwrap(),
1864            status(1),
1865            "the checked path must sign the same document the plain path does"
1866        );
1867
1868        // And a document that could never fire is not signed at all — the
1869        // point is that the signature must not exist.
1870        let mut doc = status(1);
1871        doc.yanked.clear();
1872        doc.known_problems[0].affected = vec!["2026.07.7".into()];
1873        doc.known_problems[1].affected = vec!["2026.07.0".into()];
1874        assert!(
1875            doc.sign_against(&listing(&["2026.07.0"]), false, &sk, "k")
1876                .is_err()
1877        );
1878    }
1879
1880    // rivet: verifies REQ-ADVISORY-002
1881    #[test]
1882    fn a_producer_can_list_their_own_line_without_a_network_or_an_index() {
1883        // DD-023 clause 5. `signed-index` is false by default, so an
1884        // index-only existence check would usually have nothing to check
1885        // against — and opt-in safety is how a typo'd `affected` id came to
1886        // sign cleanly and fire for nobody. The producer holds the layers.
1887        let tmp = tempfile::tempdir().unwrap();
1888        let (sk, _pk) = crate::generate_root_keypair();
1889        // REAL layouts, written by `deposit` itself. The first version of this
1890        // test built them with `DirSource::put`, which writes the bare
1891        // manifests/+blobs/ SOURCE shape — not what `varve deposit --out`
1892        // produces. It passed, and the CLI then found nothing at all. A
1893        // fixture speaking a shape the tool never emits is the defect this
1894        // release is named for, so this one uses the real writer.
1895        for (id, counter, dir) in [
1896            ("2026.08.0", 1u64, "out-a"),
1897            ("2026.08.1", 2, "out-b"),
1898            // A layer of a DIFFERENT line must not be counted as this line's.
1899            ("2026.09.0", 1, "out-other"),
1900        ] {
1901            let spec = crate::deposit::DepositSpec {
1902                layer: id.parse().unwrap(),
1903                channel: "rolling".into(),
1904                counter,
1905                issued_at: "2026-08-07T00:00:00Z".into(),
1906                tools: vec![crate::DepositTool {
1907                    name: "t".into(),
1908                    version: "1.0".into(),
1909                    platform: None,
1910                    bytes: b"x".to_vec(),
1911                    source: None,
1912                    runner: None,
1913                    kind: None,
1914                    sdk_prefix: None,
1915                }],
1916                includes: Vec::new(),
1917            };
1918            crate::deposit(&spec, &sk, "k", &tmp.path().join(dir)).unwrap();
1919        }
1920
1921        // Pointed at the PARENT of several layouts, which is the usual shape.
1922        let known = known_layers_in_layout_dirs(&[tmp.path().to_path_buf()], "2026.08");
1923        match &known {
1924            KnownLayers::Known { layers, line, .. } => {
1925                assert_eq!(layers, &["2026.08.0", "2026.08.1"], "got {layers:?}");
1926                assert_eq!(line.as_deref(), Some("2026.08"));
1927            }
1928            KnownLayers::Unknown { why } => panic!("expected a listing, got: {why}"),
1929        }
1930
1931        // …and it actually catches the typo it exists for.
1932        let mut doc = status(2);
1933        doc.line = "2026.08".into();
1934        doc.yanked = BTreeMap::from([(
1935            "2026.08.10".to_string(),
1936            "typo — never deposited".to_string(),
1937        )]);
1938        doc.known_problems.clear();
1939        let err = doc
1940            .check_layer_refs_against(&known, false)
1941            .expect_err("a yank naming a layer this line does not have must be refused");
1942        let msg = err.to_string();
1943        assert!(
1944            msg.contains("2026.08.10") && msg.contains("2026.08.0"),
1945            "the refusal must name the bad id AND the ids that exist: {msg}"
1946        );
1947
1948        // A directory holding no layout says so rather than passing clean.
1949        let empty = tempfile::tempdir().unwrap();
1950        assert!(matches!(
1951            known_layers_in_layout_dirs(&[empty.path().to_path_buf()], "2026.08"),
1952            KnownLayers::Unknown { .. }
1953        ));
1954    }
1955}