diff --git a/artifacts/requirements.yaml b/artifacts/requirements.yaml
index d9ce6e27..d7ec3a39 100644
--- a/artifacts/requirements.yaml
+++ b/artifacts/requirements.yaml
@@ -5013,3 +5013,52 @@ artifacts:
created-by: ai
model: claude-opus-5-5
timestamp: 2026-10-14T20:07:55Z
+
+ - id: REQ-COMPOSEWINDOW-001
+ type: requirement
+ title: A composition never promises support past the layers it composes, unless it says why
+ status: approved
+ release: v0.43.0
+ description: Measured on varve#25(4) The cap shall be applied by the command the deposit already calls (varve support-horizon), given the layer's manifest; a layers repository adopts it with one argument and the realm definitions it already downloads, and no second command decides the window.
+ links:
+ - type: satisfies
+ target: REQ-SUPPORTUNTIL-001
+ - type: satisfies
+ target: REQ-COMPOSE-001
+ provenance:
+ created-by: ai
+ model: claude-opus-5-5
+ timestamp: 2026-10-14T19:09:24Z
+
+ - id: REQ-ENVQUOTE-001
+ type: requirement
+ title: varve env emits the store root quoted, so the line a user is told to eval cannot execute it
+ status: approved
+ release: v0.43.0
+ description: "varve#46: env_script_sh interpolates the store root inside sh double quotes, where $ and backticks stay live; VARVE_ROOT='/tmp/vr$(id)x' runs id when the user follows 'eval \"$(varve env)\"'. The same injection class v0.19.0 removed from shim dispatch, surviving where the user is told to eval. Clauses: (1) Every shell varve env supports shall receive the path in that shell's literal quoting (sh/bash/zsh single quotes with '\\'' escaping; fish and PowerShell by their own rules). (2) A root containing each shell's metacharacters shall round-trip as the literal path, shown by running the emitted script in that shell."
+ provenance:
+ created-by: ai
+ model: claude-opus-5-5
+ timestamp: 2026-10-14T19:09:24Z
+
+ - id: REQ-NOPINNOTOOL-001
+ type: requirement
+ title: With no pin in scope, which, run and the shims refuse as status does
+ status: approved
+ release: v0.43.0
+ description: "varve#198: in a directory with no varve.toml, varve status refused (no trust root) while varve which ordeal resolved an old ordeal from the legacy pre-realm store ~/.varve/core with no verification, and the shims on PATH did the same. Two code paths answering one question differently. Clauses: (1) With no pin in scope, which, run and shim dispatch shall refuse with the same reason status gives. (2) No resolution shall read the legacy pre-realm store. (3) varve shall say the legacy store exists and that nothing in it verifies, without deleting it."
+ provenance:
+ created-by: ai
+ model: claude-opus-5-5
+ timestamp: 2026-10-14T19:09:24Z
+
+ - id: REQ-MUTANTDEBT-001
+ type: requirement
+ title: Declared mutation debt is measured in full, not cut off at a time cap
+ status: approved
+ release: v0.43.0
+ description: "varve#181: the nightly advisory mutation run is cancelled at its 90-minute cap every night after ~32% of 2,675 mutants, so 11 of the 22 not-yet files are never reached. The instrument that makes declared debt counted rather than hidden counts half of it. Clauses: (1) Every not-yet file in mutation-scope.toml shall be tested by some scheduled run that completes. (2) A run that does not complete shall be reported as incomplete, never as a measurement."
+ provenance:
+ created-by: ai
+ model: claude-opus-5-5
+ timestamp: 2026-10-14T19:09:24Z
diff --git a/crates/varve-core/src/linestatus.rs b/crates/varve-core/src/linestatus.rs
index 21db3eac..7bf87801 100644
--- a/crates/varve-core/src/linestatus.rs
+++ b/crates/varve-core/src/linestatus.rs
@@ -785,6 +785,46 @@ pub fn cache_baseline_from_source(
root_pk: &[u8],
store_root: &Path,
) -> Result, LineStatusError> {
+ match current_line_status(source, layer, line, root_pk, Published::BestEffort)? {
+ Some((envelope, doc)) => {
+ StatusCache::at_root(store_root).update(line, &envelope, &doc)?;
+ Ok(Some(doc.counter))
+ }
+ None => Ok(None),
+ }
+}
+
+/// What a failed fetch of the line's published correction means.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum Published {
+ /// Read as absence: the reader acts on the best document it can verify.
+ BestEffort,
+ /// Refuse: the reader will sign on the answer.
+ Required,
+}
+
+/// The line-status that currently speaks for `layer`: the baseline deposited
+/// beside it or the document later published under the line's own tag,
+/// whichever VERIFIES and carries the higher counter. `Ok(None)` when the
+/// source carries neither; `Err` when something was present but bad.
+///
+/// `published` says what a FAILED fetch of the correction means. Install
+/// reads it as absence ([`Published::BestEffort`]) and documents the residual.
+/// A reader about to SIGN something on the strength of the answer cannot:
+/// a correction only ever narrows what the baseline said, so "could not ask"
+/// read as "nothing newer" fails open ([`Published::Required`]).
+///
+/// One rule for every reader. The install cache and a composition reading its
+/// includes' windows (REQ-COMPOSEWINDOW-001) must agree about which document
+/// is current, or a correction that shortened a window would bind one and
+/// not the other.
+pub fn current_line_status(
+ source: &dyn crate::source::LayerSource,
+ layer: &crate::source::LayerRef,
+ line: &Line,
+ root_pk: &[u8],
+ published: Published,
+) -> Result , LineStatus)>, LineStatusError> {
let baseline = source
.fetch_line_status(layer)
.map_err(|e| LineStatusError::Payload(format!("fetching baseline line-status: {e}")))?;
@@ -793,9 +833,18 @@ pub fn cache_baseline_from_source(
// A source that cannot serve one must not be able to make that look like a
// failure, so a transport error here is treated as absence — the baseline
// path decides whether the consumer ends up with nothing.
- let published = source
- .fetch_published_line_status(&line.to_string())
- .unwrap_or(None);
+ let published = match (
+ source.fetch_published_line_status(&line.to_string()),
+ published,
+ ) {
+ (Ok(doc), _) => doc,
+ (Err(_), Published::BestEffort) => None,
+ (Err(e), Published::Required) => {
+ return Err(LineStatusError::Payload(format!(
+ "cannot tell whether a correction was published for line {line}: {e}"
+ )));
+ }
+ };
// VERIFY FIRST, RANK SECOND. Both documents are untrusted bytes from the
// party this evidence exists to constrain. Choosing by counter before
@@ -830,10 +879,7 @@ pub fn cache_baseline_from_source(
}
match best {
- Some((counter, envelope, doc)) => {
- StatusCache::at_root(store_root).update(line, &envelope, &doc)?;
- Ok(Some(counter))
- }
+ Some((_, envelope, doc)) => Ok(Some((envelope, doc))),
// Nothing usable. If something was PRESENT but bad, that is a finding
// and is raised; if both were simply absent, the source carries none.
None => match first_error {
diff --git a/crates/varve-core/src/sdkexport.rs b/crates/varve-core/src/sdkexport.rs
index 339e5050..caa856ce 100644
--- a/crates/varve-core/src/sdkexport.rs
+++ b/crates/varve-core/src/sdkexport.rs
@@ -216,6 +216,40 @@ fn find_sub(haystack: &[u8], needle: &[u8], from: usize) -> Option {
(from..=haystack.len() - needle.len()).find(|&i| &haystack[i..i + needle.len()] == needle)
}
+/// Every occurrence of `from` in `hay` replaced by `to`, and how many there
+/// were. ONE copy, used by the text path, the per-string binary path and the
+/// SYSDIRS table.
+///
+/// Written without index arithmetic on purpose. The three loops this replaced
+/// advanced with `i = hit + from.len()`, and cargo-mutants' `+` → `*` turned
+/// that into a loop that never advances when the hit is at 0 — while
+/// appending on every pass. It did not just hang: it grew a buffer until the
+/// CI runner ran out of memory and was shut down, twice, taking the whole
+/// mutation shard with it. Slicing with `get` leaves no operator for that
+/// mutant, and the loop below cannot run without consuming input.
+fn replace_all(hay: &[u8], from: &[u8], to: &[u8]) -> (Vec, usize) {
+ let mut out = Vec::with_capacity(hay.len());
+ let mut rest = hay;
+ let mut count = 0;
+ // Every pass must CONSUME input or stop. Advancing only past a match
+ // that lies wholly inside `rest` makes termination independent of what
+ // `find_sub` answers: a mutant `find_sub` that always says `Some(0)`
+ // once spun this loop forever on an empty `rest`, appending each time,
+ // until the CI runner ran out of memory — the failure it was written to
+ // end, reached by a different mutant (2026-10-16).
+ while let Some(at) = find_sub(rest, from, 0) {
+ let Some(tail) = rest.get(at..).and_then(|t| t.get(from.len()..)) else {
+ break;
+ };
+ out.extend_from_slice(&rest[..at]);
+ out.extend_from_slice(to);
+ rest = tail;
+ count += 1;
+ }
+ out.extend_from_slice(rest);
+ (out, count)
+}
+
/// What relocating one member's bytes produced.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Relocation {
@@ -253,16 +287,7 @@ pub fn relocate_bytes(
if !is_binary(bytes) {
// The `sed -i` half: no field to overflow, so the text may change length.
- let mut out = Vec::with_capacity(bytes.len());
- let mut i = 0;
- let mut substitutions = 0;
- while let Some(hit) = find_sub(bytes, built, i) {
- out.extend_from_slice(&bytes[i..hit]);
- out.extend_from_slice(dest);
- i = hit + built.len();
- substitutions += 1;
- }
- out.extend_from_slice(&bytes[i..]);
+ let (out, substitutions) = replace_all(bytes, built, dest);
return Ok(Relocation {
bytes: out,
fields: 0,
@@ -278,6 +303,12 @@ pub fn relocate_bytes(
let mut fields = sysdirs.as_ref().map_or(0, |(_, n)| *n);
let mut cursor = 0;
while let Some(hit) = find_sub(&out, built, cursor) {
+ // A hit behind the cursor is a search that did not advance; stop
+ // rather than re-patch the same field forever (a mutant `find_sub`
+ // returning a fixed position hung here until the test timed out).
+ if hit < cursor {
+ break;
+ }
if let Some((range, _)) = &sysdirs
&& range.contains(&hit)
{
@@ -317,15 +348,7 @@ pub fn relocate_bytes(
let capacity = pad_end - start;
// Replace every occurrence of the prefix WITHIN this one string.
- let old = out[start..end].to_vec();
- let mut new = Vec::with_capacity(old.len());
- let mut i = 0;
- while let Some(h) = find_sub(&old, built, i) {
- new.extend_from_slice(&old[i..h]);
- new.extend_from_slice(dest);
- i = h + built.len();
- }
- new.extend_from_slice(&old[i..]);
+ let (new, _) = replace_all(&out[start..end], built, dest);
// `if len(new_dl_path) >= p_filesz: ERROR` — transcribed, not
// paraphrased. The destination check has already made this
@@ -447,17 +470,7 @@ fn relocate_sysdirs(
let paths: Vec> = table
.split(|b| *b == 0)
.take_while(|p| !p.is_empty())
- .map(|p| {
- let mut new = Vec::with_capacity(p.len());
- let mut i = 0;
- while let Some(h) = find_sub(p, built, i) {
- new.extend_from_slice(&p[i..h]);
- new.extend_from_slice(dest);
- i = h + built.len();
- }
- new.extend_from_slice(&p[i..]);
- new
- })
+ .map(|p| replace_all(p, built, dest).0)
.collect();
let width = if class64 { 8 } else { 4 };
let slots = len_size / width;
diff --git a/crates/varve-core/src/support.rs b/crates/varve-core/src/support.rs
index d1485ae8..2adff5a7 100644
--- a/crates/varve-core/src/support.rs
+++ b/crates/varve-core/src/support.rs
@@ -67,7 +67,18 @@ impl Policy {
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SupportError {
UnknownChannel(String),
- BadDate { field: &'static str, value: String },
+ BadDate {
+ field: &'static str,
+ value: String,
+ },
+ /// An include's own signed line-status states no window, so nothing
+ /// bounds what the composition may promise about it.
+ IncludeStatesNoWindow {
+ layer: String,
+ realm: String,
+ },
+ /// `--past-includes` was given with nothing in it.
+ OverrideWithoutReason,
}
impl fmt::Display for SupportError {
@@ -85,6 +96,18 @@ impl fmt::Display for SupportError {
field has never been validated, so an unparseable value would \
sign cleanly and then be unusable by everything that reads it."
),
+ SupportError::IncludeStatesNoWindow { layer, realm } => write!(
+ f,
+ "included layer {layer} (realm '{realm}') states no support window, so \
+ nothing bounds how long this composition may promise to support it. \
+ Refusing rather than skipping it: the include nobody vouched for is \
+ the one that would have set the cap."
+ ),
+ SupportError::OverrideWithoutReason => write!(
+ f,
+ "promising support past an included layer's window needs a reason on \
+ the record; an empty one is not a decision"
+ ),
}
}
}
@@ -130,6 +153,168 @@ fn days_in_month(y: i64, m: i64) -> i64 {
}
}
+/// One included layer's stated window, read from that layer's OWN signed
+/// line-status and verified against ITS realm's root by the caller.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct IncludeWindow {
+ pub layer: String,
+ pub realm: String,
+ /// `None` when the include's line-status states no window.
+ pub support_until: Option,
+}
+
+/// What set a composition's horizon.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum Bound {
+ /// The layer's own channel policy; no include ends sooner.
+ Own,
+ /// This include ends sooner, and the composition promises no more.
+ Include { layer: String, realm: String },
+ /// The composition promises past `earliest`, deliberately, for `reason`.
+ PastIncludes { earliest: String, reason: String },
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct Capped {
+ pub until: String,
+ pub bound_by: Bound,
+}
+
+/// Cap a composition's horizon at the earliest window among its includes
+/// (REQ-COMPOSEWINDOW-001).
+///
+/// A composition's window was derived from its own channel alone, so covalent
+/// 2026.10.0 signed support until 2026-11-02 over a wasm layer whose own realm
+/// stops vouching for it on 2026-10-24 (varve#254). The composition cannot
+/// support bytes longer than the realm that publishes them does.
+///
+/// Capping, not refusing, is the default: an auto-deposit should produce the
+/// true window without a person. Promising longer is possible and takes a
+/// `past_includes` reason, which the caller prints beside the signed date.
+pub fn cap_by_includes(
+ own: &str,
+ includes: &[IncludeWindow],
+ past_includes: Option<&str>,
+) -> Result {
+ let own_days = epoch_days(own).ok_or_else(|| SupportError::BadDate {
+ field: "support-until",
+ value: own.to_string(),
+ })?;
+ let mut earliest: Option<(i64, &IncludeWindow, &str)> = None;
+ for inc in includes {
+ let until =
+ inc.support_until
+ .as_deref()
+ .ok_or_else(|| SupportError::IncludeStatesNoWindow {
+ layer: inc.layer.clone(),
+ realm: inc.realm.clone(),
+ })?;
+ let days = epoch_days(until).ok_or_else(|| SupportError::BadDate {
+ field: "include support-until",
+ value: until.to_string(),
+ })?;
+ if earliest.is_none_or(|(e, _, _)| days < e) {
+ earliest = Some((days, inc, until));
+ }
+ }
+ let own_stands = Capped {
+ until: own.to_string(),
+ bound_by: Bound::Own,
+ };
+ let Some((_, inc, until)) = earliest.filter(|(d, _, _)| *d < own_days) else {
+ return Ok(own_stands);
+ };
+ match past_includes {
+ Some(reason) if reason.trim().is_empty() => Err(SupportError::OverrideWithoutReason),
+ Some(reason) => Ok(Capped {
+ until: own.to_string(),
+ bound_by: Bound::PastIncludes {
+ earliest: until.to_string(),
+ reason: reason.trim().to_string(),
+ },
+ }),
+ None => Ok(Capped {
+ until: until.to_string(),
+ bound_by: Bound::Include {
+ layer: inc.layer.clone(),
+ realm: inc.realm.clone(),
+ },
+ }),
+ }
+}
+
+/// Why one include's window could not be read.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct IncludeWindowError {
+ /// The include, by digest — all that is known before its manifest verifies.
+ pub include: String,
+ pub realm: String,
+ pub reason: String,
+}
+
+impl fmt::Display for IncludeWindowError {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ write!(
+ f,
+ "cannot read the support window of included layer {} (realm '{}'): {}",
+ self.include, self.realm, self.reason
+ )
+ }
+}
+
+impl std::error::Error for IncludeWindowError {}
+
+/// Read one include's stated window from the realm that vouches for it.
+///
+/// `source`, `verifier` and `root_pk` are THAT realm's — never the composing
+/// realm's. The manifest is verified first, so the layer id the window is
+/// reported against is one the realm signed; the line-status is then chosen by
+/// [`crate::linestatus::current_line_status`], the same rule install uses, so
+/// a correction that shortened the window binds here too.
+///
+/// No status at all yields `support_until: None`, which `cap_by_includes`
+/// refuses. A status that is present and bad is an error.
+pub fn include_window(
+ source: &dyn crate::source::LayerSource,
+ verifier: &dyn crate::install::ManifestVerifier,
+ root_pk: &[u8],
+ digest: &str,
+ realm: &str,
+) -> Result {
+ let fail = |reason: String| IncludeWindowError {
+ include: digest.to_string(),
+ realm: realm.to_string(),
+ reason,
+ };
+ let layer_ref = crate::source::LayerRef::Digest(digest.to_string());
+ let fetched = source
+ .fetch_manifest(&layer_ref)
+ .map_err(|e| fail(format!("fetching its manifest: {e}")))?;
+ let payload = verifier
+ .verify(&fetched)
+ .map_err(|e| fail(format!("its manifest does not verify: {}", e.0)))?;
+ if crate::store::manifest_digest(&payload) != digest {
+ return Err(fail(
+ "the manifest served for this digest hashes to something else".to_string(),
+ ));
+ }
+ let manifest = crate::manifest::LayerManifest::parse(&payload)
+ .map_err(|e| fail(format!("its manifest does not parse: {e}")))?;
+ let status = crate::linestatus::current_line_status(
+ source,
+ &layer_ref,
+ manifest.layer.line(),
+ root_pk,
+ crate::linestatus::Published::Required,
+ )
+ .map_err(|e| fail(format!("layer {}: its line-status: {e}", manifest.layer)))?;
+ Ok(IncludeWindow {
+ layer: manifest.layer.to_string(),
+ realm: realm.to_string(),
+ support_until: status.and_then(|(_, doc)| doc.report_for(&manifest.layer).support_until),
+ })
+}
+
/// Where a layer stands against its stated horizon.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Standing {
@@ -358,4 +543,347 @@ mod tests {
);
assert!(far.contains("(382 days)"), "{far}");
}
+
+ fn inc(layer: &str, until: Option<&str>) -> IncludeWindow {
+ IncludeWindow {
+ layer: layer.to_string(),
+ realm: "wasm".to_string(),
+ support_until: until.map(str::to_string),
+ }
+ }
+
+ /// The measured case from varve#254: covalent's own channel says
+ /// 2026-11-02, the wasm layer it includes says 2026-10-24.
+ // rivet: verifies REQ-COMPOSEWINDOW-001
+ #[test]
+ fn a_composition_is_capped_at_its_earliest_include() {
+ let got = cap_by_includes(
+ "2026-11-02",
+ &[
+ inc("2026.10.0", Some("2026-10-24")),
+ inc("2026.10.3", Some("2026-10-30")),
+ ],
+ None,
+ )
+ .unwrap();
+ assert_eq!(got.until, "2026-10-24");
+ assert_eq!(
+ got.bound_by,
+ Bound::Include {
+ layer: "2026.10.0".into(),
+ realm: "wasm".into()
+ }
+ );
+ }
+
+ /// The earliest include binds wherever it is listed, and on a tie the
+ /// first listed is named, so the message is the same on every run.
+ // rivet: verifies REQ-COMPOSEWINDOW-001
+ #[test]
+ fn the_earliest_include_binds_wherever_it_is_listed() {
+ let got = cap_by_includes(
+ "2026-11-02",
+ &[
+ inc("late", Some("2026-10-30")),
+ inc("early", Some("2026-10-24")),
+ inc("tied", Some("2026-10-24")),
+ ],
+ None,
+ )
+ .unwrap();
+ assert_eq!(got.until, "2026-10-24");
+ assert_eq!(
+ got.bound_by,
+ Bound::Include {
+ layer: "early".into(),
+ realm: "wasm".into()
+ }
+ );
+ }
+
+ /// An include supported for longer than the composition does not lengthen
+ /// it, and a layer with no includes keeps its own window.
+ // rivet: verifies REQ-COMPOSEWINDOW-001
+ #[test]
+ fn the_own_window_stands_when_it_is_already_the_shortest() {
+ let got = cap_by_includes("2026-11-02", &[inc("x", Some("2027-01-01"))], None).unwrap();
+ assert_eq!(
+ (got.until.as_str(), &got.bound_by),
+ ("2026-11-02", &Bound::Own)
+ );
+ let alone = cap_by_includes("2026-11-02", &[], None).unwrap();
+ assert_eq!(
+ (alone.until.as_str(), &alone.bound_by),
+ ("2026-11-02", &Bound::Own)
+ );
+ }
+
+ /// The same day is not an overshoot: "through that day" on both sides.
+ // rivet: verifies REQ-COMPOSEWINDOW-001
+ #[test]
+ fn an_include_ending_the_same_day_is_not_a_cap() {
+ let got = cap_by_includes("2026-11-02", &[inc("x", Some("2026-11-02"))], None).unwrap();
+ assert_eq!(got.bound_by, Bound::Own);
+ }
+
+ /// Clause 2: an include that states no window is not skipped. Skipping it
+ /// is how the cap goes vacuous — the one include nobody vouched for is
+ /// exactly the one that would have bound it.
+ // rivet: verifies REQ-COMPOSEWINDOW-001
+ #[test]
+ fn an_include_that_states_no_window_refuses_rather_than_being_skipped() {
+ let err = cap_by_includes(
+ "2026-11-02",
+ &[inc("a", Some("2026-12-01")), inc("b", None)],
+ None,
+ )
+ .unwrap_err();
+ assert_eq!(
+ err,
+ SupportError::IncludeStatesNoWindow {
+ layer: "b".into(),
+ realm: "wasm".into()
+ }
+ );
+ assert!(err.to_string().contains("b"), "{err}");
+ }
+
+ /// A malformed include date refuses rather than comparing as text.
+ // rivet: verifies REQ-COMPOSEWINDOW-001
+ #[test]
+ fn an_unparseable_include_window_refuses() {
+ let err = cap_by_includes("2026-11-02", &[inc("a", Some("soon"))], None).unwrap_err();
+ assert!(matches!(err, SupportError::BadDate { .. }), "{err:?}");
+ }
+
+ /// Clause 3: a longer promise is possible, and it is a decision with a
+ /// reason on the record — the reason is carried out so the caller can
+ /// print it beside the signed date.
+ // rivet: verifies REQ-COMPOSEWINDOW-001
+ #[test]
+ fn exceeding_the_cap_takes_a_stated_reason() {
+ let got = cap_by_includes(
+ "2026-11-02",
+ &[inc("2026.10.0", Some("2026-10-24"))],
+ Some("wasm-layers 2026.11.0 is staged and carries the same tools"),
+ )
+ .unwrap();
+ assert_eq!(got.until, "2026-11-02");
+ assert_eq!(
+ got.bound_by,
+ Bound::PastIncludes {
+ earliest: "2026-10-24".into(),
+ reason: "wasm-layers 2026.11.0 is staged and carries the same tools".into()
+ }
+ );
+ }
+
+ /// An override that says nothing is not a decision.
+ // rivet: verifies REQ-COMPOSEWINDOW-001
+ #[test]
+ fn an_override_without_a_reason_is_refused() {
+ let err =
+ cap_by_includes("2026-11-02", &[inc("a", Some("2026-10-24"))], Some(" ")).unwrap_err();
+ assert_eq!(err, SupportError::OverrideWithoutReason);
+ assert!(!err.to_string().is_empty());
+ }
+
+ /// An override that was not needed changes nothing: it must not be
+ /// recorded as though a longer promise had been made.
+ // rivet: verifies REQ-COMPOSEWINDOW-001
+ #[test]
+ fn an_unneeded_override_is_not_recorded() {
+ let got = cap_by_includes(
+ "2026-11-02",
+ &[inc("a", Some("2027-01-01"))],
+ Some("because"),
+ )
+ .unwrap();
+ assert_eq!(got.bound_by, Bound::Own);
+ }
+
+ mod from_source {
+ use super::super::*;
+ use crate::install::{ManifestVerifier, VerifyError};
+ use crate::linestatus::LineStatus;
+ use crate::manifest::fixtures::manifest_with_tools;
+ use crate::source::MemorySource;
+ use crate::store::manifest_digest;
+ use crate::verify::generate_root_keypair;
+
+ struct AcceptAll;
+ impl ManifestVerifier for AcceptAll {
+ fn verify(&self, fetched: &[u8]) -> Result, VerifyError> {
+ Ok(fetched.to_vec())
+ }
+ }
+ struct RejectAll;
+ impl ManifestVerifier for RejectAll {
+ fn verify(&self, _: &[u8]) -> Result, VerifyError> {
+ Err(VerifyError("untrusted signature (test)".into()))
+ }
+ }
+
+ fn wasm_layer() -> Vec {
+ manifest_with_tools("2026.10.0", "rolling", 0, "2026-09-24T00:00:00Z", &[])
+ }
+
+ fn status(counter: u64, until: Option<&str>) -> LineStatus {
+ LineStatus {
+ min_counter: None,
+ line: "2026.10".into(),
+ counter,
+ issued_at: "2026-09-24T00:00:00Z".into(),
+ support_until: until.map(str::to_string),
+ yanked: Default::default(),
+ known_problems: Vec::new(),
+ }
+ }
+
+ /// The include's window is read from ITS realm's signed status, and
+ /// names the layer the digest turned out to be.
+ // rivet: verifies REQ-COMPOSEWINDOW-001
+ #[test]
+ fn an_include_window_comes_from_its_own_signed_status() {
+ let (sk, pk) = generate_root_keypair();
+ let bytes = wasm_layer();
+ let source = MemorySource::new().with_manifest(&bytes).with_line_status(
+ status(1, Some("2026-10-24"))
+ .sign(&sk, "k")
+ .unwrap()
+ .as_bytes(),
+ );
+ let w =
+ include_window(&source, &AcceptAll, &pk, &manifest_digest(&bytes), "wasm").unwrap();
+ assert_eq!(
+ w,
+ IncludeWindow {
+ layer: "2026.10.0".into(),
+ realm: "wasm".into(),
+ support_until: Some("2026-10-24".into()),
+ }
+ );
+ }
+
+ /// A later correction that SHORTENS the window binds the cap — the
+ /// same document install would cache.
+ // rivet: verifies REQ-COMPOSEWINDOW-001
+ #[test]
+ fn a_published_correction_overtakes_the_baseline_window() {
+ let (sk, pk) = generate_root_keypair();
+ let bytes = wasm_layer();
+ let source = MemorySource::new()
+ .with_manifest(&bytes)
+ .with_line_status(
+ status(1, Some("2026-10-24"))
+ .sign(&sk, "k")
+ .unwrap()
+ .as_bytes(),
+ )
+ .with_published_line_status(
+ status(5, Some("2026-10-10"))
+ .sign(&sk, "k")
+ .unwrap()
+ .as_bytes(),
+ );
+ let w =
+ include_window(&source, &AcceptAll, &pk, &manifest_digest(&bytes), "wasm").unwrap();
+ assert_eq!(w.support_until.as_deref(), Some("2026-10-10"));
+ }
+
+ /// A status signed by any other key is not that realm speaking.
+ // rivet: verifies REQ-COMPOSEWINDOW-001
+ #[test]
+ fn a_status_not_signed_by_the_includes_realm_is_refused() {
+ let (sk_other, _) = generate_root_keypair();
+ let (_, pk) = generate_root_keypair();
+ let bytes = wasm_layer();
+ let source = MemorySource::new().with_manifest(&bytes).with_line_status(
+ status(1, Some("2027-12-31"))
+ .sign(&sk_other, "k")
+ .unwrap()
+ .as_bytes(),
+ );
+ let err = include_window(&source, &AcceptAll, &pk, &manifest_digest(&bytes), "wasm")
+ .unwrap_err();
+ assert!(
+ err.to_string().contains("2026.10.0") || err.to_string().contains("sha256:"),
+ "{err}"
+ );
+ }
+
+ /// A source that cannot SAY whether a correction was published must
+ /// not be read as "none was". A correction can only shorten what the
+ /// baseline promised, so treating a failed fetch as absence fails open:
+ /// the composition would sign the longer, superseded window. Install
+ /// tolerates that (best-effort floor); a signed promise must not.
+ // rivet: verifies REQ-COMPOSEWINDOW-001
+ #[test]
+ fn a_correction_that_cannot_be_fetched_refuses_rather_than_reading_as_none() {
+ use crate::source::{LayerRef, LayerSource, SourceError};
+ struct CorrectionUnreachable(MemorySource);
+ impl LayerSource for CorrectionUnreachable {
+ fn fetch_manifest(&self, l: &LayerRef) -> Result, SourceError> {
+ self.0.fetch_manifest(l)
+ }
+ fn fetch_blob(&self, d: &str) -> Result, SourceError> {
+ self.0.fetch_blob(d)
+ }
+ fn fetch_line_status(&self, l: &LayerRef) -> Result>, SourceError> {
+ self.0.fetch_line_status(l)
+ }
+ fn fetch_published_line_status(
+ &self,
+ _: &str,
+ ) -> Result >, SourceError> {
+ Err(SourceError::Transport("connection reset".into()))
+ }
+ }
+ let (sk, pk) = generate_root_keypair();
+ let bytes = wasm_layer();
+ let source = CorrectionUnreachable(
+ MemorySource::new().with_manifest(&bytes).with_line_status(
+ status(1, Some("2026-10-24"))
+ .sign(&sk, "k")
+ .unwrap()
+ .as_bytes(),
+ ),
+ );
+ let err = include_window(&source, &AcceptAll, &pk, &manifest_digest(&bytes), "wasm")
+ .unwrap_err();
+ assert!(err.reason.contains("connection reset"), "{err}");
+ }
+
+ /// No status at all is a window nobody stated — carried as `None`, so
+ /// `cap_by_includes` refuses it (clause 2) rather than skipping it.
+ // rivet: verifies REQ-COMPOSEWINDOW-001
+ #[test]
+ fn an_include_with_no_status_states_no_window() {
+ let (_, pk) = generate_root_keypair();
+ let bytes = wasm_layer();
+ let source = MemorySource::new().with_manifest(&bytes);
+ let w =
+ include_window(&source, &AcceptAll, &pk, &manifest_digest(&bytes), "wasm").unwrap();
+ assert_eq!(w.support_until, None);
+ assert!(cap_by_includes("2026-11-02", &[w], None).is_err());
+ }
+
+ /// The include's manifest must verify under its realm before anything
+ /// it says about itself is believed.
+ // rivet: verifies REQ-COMPOSEWINDOW-001
+ #[test]
+ fn an_include_whose_manifest_does_not_verify_is_refused() {
+ let (sk, pk) = generate_root_keypair();
+ let bytes = wasm_layer();
+ let source = MemorySource::new().with_manifest(&bytes).with_line_status(
+ status(1, Some("2026-10-24"))
+ .sign(&sk, "k")
+ .unwrap()
+ .as_bytes(),
+ );
+ assert!(
+ include_window(&source, &RejectAll, &pk, &manifest_digest(&bytes), "wasm").is_err()
+ );
+ }
+ }
}
diff --git a/crates/varve/docs/cmd-support-horizon.md b/crates/varve/docs/cmd-support-horizon.md
index b040503a..185d1209 100644
--- a/crates/varve/docs/cmd-support-horizon.md
+++ b/crates/varve/docs/cmd-support-horizon.md
@@ -8,7 +8,17 @@ varve support-horizon --channel rolling --issued-at 2026-09-03T00:00:00Z
# 2027-03-03
```
-`--json` emits `{"channel", "issued_at", "support_until"}` for a pipeline.
+`--json` emits `{"channel", "issued_at", "support_until", "policy_until",
+"bound_by"}` for a pipeline.
+
+A layer that composes others passes its `layer.toml`:
+
+```sh
+varve support-horizon --channel rolling --issued-at 2026-10-02T10:00:00Z \
+ --manifest layer.toml
+# support capped at 2026-10-24 by included layer 2026.09.0 (realm 'pulseengine-wasm'); ... (stderr)
+# 2026-10-24 (stdout)
+```
## Why this is derived and not typed
@@ -46,10 +56,37 @@ is pointed at, and the one whose contents are deliberately frozen.
An unknown channel is refused rather than given a default. A horizon nobody
decided is a promise nobody made, and it would be signed with the realm's root.
+## A composition cannot outlast what it composes
+
+covalent 2026.10.0 signed support until **2026-11-02** while the wasm-layers
+layer it includes was supported only until **2026-10-24** (varve#254). The
+composition promised nine days of support for bytes whose own realm had stopped
+vouching for them, and signed that promise with its root.
+
+With `--manifest`, every `[[include]]` is read from **its own realm**: the
+include's manifest is fetched by digest and verified against that realm's
+root, then its current line-status (the baseline, or a later published
+correction, whichever verifies and is newer) supplies its window. The horizon
+is the earliest of the channel's policy and every include's window.
+
+- **Capping is the default**, so an automatic deposit states the true window
+ without a person.
+- **An include that states no window refuses.** Skipping it would make the cap
+ vacuous: the include nobody vouched for is exactly the one that would have
+ bound it.
+- **`--past-includes ""` promises longer anyway.** The reason is
+ printed beside the date, so a longer promise is a decision on the record.
+ An empty reason is refused.
+
+The realms the includes name must be defined in a `varve-realms.toml` found
+from the working directory, exactly as for `install`.
+
## What it does not do
+
It does not sample a clock beyond the `--issued-at` you give it, and it does
-not write anything. The caller puts the result in the status document, and
+not write anything. Without `--manifest` it makes no network request; with
+one, it reads only the includes' manifests and line-statuses. The caller puts the result in the status document, and
`varve sign-status` refuses a document without one.
## Reading it back
diff --git a/crates/varve/src/main.rs b/crates/varve/src/main.rs
index 067f051c..c6b09627 100644
--- a/crates/varve/src/main.rs
+++ b/crates/varve/src/main.rs
@@ -205,6 +205,16 @@ enum Cmd {
/// The layer's issued-at, RFC 3339.
#[arg(long = "issued-at")]
issued_at: String,
+ /// The layer's `layer.toml`. When it composes other layers, the
+ /// horizon is capped at the earliest window among them, read from each
+ /// include's own realm (REQ-COMPOSEWINDOW-001).
+ #[arg(long)]
+ manifest: Option,
+ /// Promise support past an include's window anyway, for this reason.
+ /// The reason is printed beside the date so the decision is on the
+ /// record.
+ #[arg(long = "past-includes", value_name = "REASON")]
+ past_includes: Option,
#[arg(long)]
json: bool,
},
@@ -871,24 +881,16 @@ fn run() -> anyhow::Result {
Cmd::SupportHorizon {
channel,
issued_at,
+ manifest,
+ past_includes,
json,
- } => {
- let until = varve_core::support::horizon(&issued_at, &channel)?;
- if json {
- println!(
- "{}",
- serde_json::json!({
- "command": "support-horizon",
- "channel": channel,
- "issued_at": issued_at,
- "support_until": until,
- })
- );
- } else {
- println!("{until}");
- }
- Ok(())
- }
+ } => support_horizon(
+ &channel,
+ &issued_at,
+ manifest.as_deref(),
+ past_includes.as_deref(),
+ json,
+ ),
Cmd::Keygen { out, public } => keygen(&out, public.as_deref()),
Cmd::Pubkey { key } => pubkey(&key),
Cmd::LayerSpec { manifest, json } => layer_spec(&manifest, json),
@@ -3382,6 +3384,101 @@ fn today_rfc3339() -> String {
format!("{y:04}-{m:02}-{d:02}T00:00:00Z")
}
+/// `varve support-horizon` — the channel's window, capped at the earliest
+/// window among the layers this one composes (REQ-SUPPORTUNTIL-001,
+/// REQ-COMPOSEWINDOW-001).
+///
+/// The date alone goes to stdout, as it always has, so a workflow's
+/// `SUPPORT_UNTIL="$(varve support-horizon ...)"` keeps working; what bound
+/// it goes to stderr, where a deposit log keeps it.
+fn support_horizon(
+ channel: &str,
+ issued_at: &str,
+ manifest: Option<&std::path::Path>,
+ past_includes: Option<&str>,
+ json: bool,
+) -> anyhow::Result<()> {
+ use varve_core::support::{self, Bound};
+ let own = support::horizon(issued_at, channel)?;
+ let includes = match manifest {
+ Some(path) => {
+ let text = std::fs::read_to_string(path)
+ .with_context(|| format!("cannot read {}", path.display()))?;
+ varve_core::layerspec::parse_layer_manifest(&text)
+ .with_context(|| format!("{} does not parse", path.display()))?
+ .includes
+ }
+ None => Vec::new(),
+ };
+ let cwd = std::env::current_dir().context("cannot determine working directory")?;
+ let mut windows = Vec::new();
+ for inc in &includes {
+ let Some(realm_name) = inc.realm.as_deref() else {
+ bail!(
+ "include {} names no realm, so no root vouches for its support window",
+ inc.digest
+ );
+ };
+ let realm = varve_core::resolve_realm(&cwd, realm_name).with_context(|| {
+ format!(
+ "include {} names realm '{realm_name}', which is not defined here",
+ inc.digest
+ )
+ })?;
+ let source = varve_core::RegistrySource::parse(&realm.registry)?;
+ let verifier = varve_core::PinnedKeyVerifier::from_public_key_bytes(&realm.trust_root)
+ .map_err(|e| anyhow::anyhow!(e.to_string()))?;
+ windows.push(support::include_window(
+ &source,
+ &verifier,
+ &realm.trust_root,
+ &inc.digest,
+ realm_name,
+ )?);
+ }
+ let capped = support::cap_by_includes(&own, &windows, past_includes)?;
+ let note = match &capped.bound_by {
+ Bound::Own => None,
+ Bound::Include { layer, realm } => Some(format!(
+ "support capped at {} by included layer {layer} (realm '{realm}'); the {channel} \
+ policy alone would say {own}",
+ capped.until
+ )),
+ Bound::PastIncludes { earliest, reason } => Some(format!(
+ "support stated past an included layer's window ({earliest}) to {}, because: {reason}",
+ capped.until
+ )),
+ };
+ if json {
+ let bound_by = match &capped.bound_by {
+ Bound::Own => serde_json::json!({ "kind": "own" }),
+ Bound::Include { layer, realm } => {
+ serde_json::json!({ "kind": "include", "layer": layer, "realm": realm })
+ }
+ Bound::PastIncludes { earliest, reason } => {
+ serde_json::json!({ "kind": "past-includes", "earliest": earliest, "reason": reason })
+ }
+ };
+ println!(
+ "{}",
+ serde_json::json!({
+ "command": "support-horizon",
+ "channel": channel,
+ "issued_at": issued_at,
+ "support_until": capped.until,
+ "policy_until": own,
+ "bound_by": bound_by,
+ })
+ );
+ } else {
+ if let Some(note) = ¬e {
+ eprintln!("{note}");
+ }
+ println!("{}", capped.until);
+ }
+ Ok(())
+}
+
/// Fetch one layer a composition names, from the realm that vouches for it.
///
/// The realm decides everything: its registry is where the bytes come from,
diff --git a/crates/varve/tests/cli.rs b/crates/varve/tests/cli.rs
index 43ba801c..b12ffe26 100644
--- a/crates/varve/tests/cli.rs
+++ b/crates/varve/tests/cli.rs
@@ -7052,3 +7052,67 @@ fn the_timing_report_goes_to_stderr_and_not_to_stdout() {
to skip:\n{body}"
);
}
+
+const LAYER_TOML_NO_INCLUDES: &str = r#"
+[varve]
+version = "v0.41.0"
+
+[realm]
+name = "pulseengine"
+channel = "rolling"
+registry = "oci://ghcr.io/pulseengine/layers"
+
+[[tool]]
+name = "rivet"
+version = "v0.34.0"
+"#;
+
+/// A layer that composes nothing keeps its channel's window, and stdout stays
+/// the bare date a workflow captures with `$(...)`.
+// rivet: verifies REQ-COMPOSEWINDOW-001
+#[test]
+fn support_horizon_with_a_manifest_that_composes_nothing_keeps_the_policy_date() {
+ let fx = fixture(None, &[]);
+ std::fs::write(fx.project.join("layer.toml"), LAYER_TOML_NO_INCLUDES).unwrap();
+ varve(&fx)
+ .args([
+ "support-horizon",
+ "--channel",
+ "rolling",
+ "--issued-at",
+ "2026-10-02T10:00:00Z",
+ "--manifest",
+ "layer.toml",
+ ])
+ .assert()
+ .success()
+ .stdout("2026-11-02\n")
+ .stderr("");
+}
+
+/// An include whose realm is not defined here cannot be read, and the horizon
+/// is refused rather than computed without it (clause 2).
+// rivet: verifies REQ-COMPOSEWINDOW-001
+#[test]
+fn support_horizon_refuses_an_include_from_an_undefined_realm() {
+ let fx = fixture(None, &[]);
+ let text = format!(
+ "{LAYER_TOML_NO_INCLUDES}\n[[include]]\ndigest = \"sha256:{}\"\nrealm = \"nowhere\"\nlayer = \"2026.09.0\"\n",
+ "a".repeat(64)
+ );
+ std::fs::write(fx.project.join("layer.toml"), text).unwrap();
+ varve(&fx)
+ .args([
+ "support-horizon",
+ "--channel",
+ "rolling",
+ "--issued-at",
+ "2026-10-02T10:00:00Z",
+ "--manifest",
+ "layer.toml",
+ ])
+ .assert()
+ .failure()
+ .stdout("")
+ .stderr(predicate::str::contains("nowhere"));
+}