From 708228de41428c963aa178bc81b0c381b502cd2f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 12:50:25 +0000 Subject: [PATCH 1/4] fix(serve): /api/v1/artifacts and /api/v1/diagnostics carry a truncation signal (REQ-303, #832) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## The gap `/api/v1/artifacts` caps `limit` at 1000 and this repo now holds 1017 artifacts, so a client asking for everything received 1000 rows with no indication that 17 were dropped. The response carried `total: 1017` next to a 1000-element array — the data was present, but nothing marked the payload as partial, and every consumer reading `artifacts` as the full set was silently wrong. Same shape at `/api/v1/diagnostics`, which uses the same `.min(1000)` cap on its `limit`. The immediate downstream cost is already visible: `serve_integration.rs` tests assert `externals_unscoped > 0` on `origin=all&limit=1000`, i.e. they assume that window is the full set — a premise that holds today only because the externals happen to sort into positions 0-3. ## The fix Step 1 from the issue: emit `count` and `truncated` alongside `total` on both responses, matching the shape `rivet query --format json` already uses (see `schemas/json/query-output.schema.json`). Consumers can now fail loudly on a partial view instead of quietly reading it as the full set. Additive-only — existing clients that read only `total` and `artifacts` are unaffected. The determinism half (#746 / #759) and the cap-as-paging-boundary decision remain out of scope for this change; Step 1 alone converts silent truncation into detectable truncation, and lands cleanly on its own. Fixes: REQ-303 Verifies: REQ-303 Refs: #832 --- rivet-cli/src/serve/api.rs | 12 ++++ rivet-cli/tests/serve_integration.rs | 104 +++++++++++++++++++++++++++ 2 files changed, 116 insertions(+) diff --git a/rivet-cli/src/serve/api.rs b/rivet-cli/src/serve/api.rs index b912105c..bb7ec818 100644 --- a/rivet-cli/src/serve/api.rs +++ b/rivet-cli/src/serve/api.rs @@ -448,6 +448,8 @@ fn default_limit() -> u32 { #[derive(Serialize)] struct ArtifactsResponse { total: usize, + count: usize, + truncated: bool, artifacts: Vec, } @@ -584,9 +586,13 @@ pub(crate) async fn artifacts( let total = results.len(); let page: Vec = results.into_iter().skip(offset).take(limit).collect(); + let count = page.len(); + let truncated = count < total; Json(ArtifactsResponse { total, + count, + truncated, artifacts: page, }) .into_response() @@ -619,6 +625,8 @@ struct ApiDiagnostic { #[derive(Serialize)] struct DiagnosticsResponse { total: usize, + count: usize, + truncated: bool, diagnostics: Vec, } @@ -686,9 +694,13 @@ pub(crate) async fn diagnostics( let total = results.len(); let page: Vec = results.into_iter().skip(offset).take(limit).collect(); + let count = page.len(); + let truncated = count < total; Json(DiagnosticsResponse { total, + count, + truncated, diagnostics: page, }) } diff --git a/rivet-cli/tests/serve_integration.rs b/rivet-cli/tests/serve_integration.rs index 3f5325a3..d8d42b8b 100644 --- a/rivet-cli/tests/serve_integration.rs +++ b/rivet-cli/tests/serve_integration.rs @@ -777,6 +777,56 @@ fn api_artifacts_pagination() { child.wait().ok(); } +/// #832 / REQ-303: silent truncation is a weak-green defect. The response +/// must carry the data needed for a consumer to detect a partial view: +/// `count` alongside `total`, and `truncated: true` when the page did not +/// return the full set. Otherwise every consumer reading `artifacts` as the +/// full set is silently wrong. +/// +/// rivet: verifies REQ-303 +#[test] +fn api_artifacts_truncation_signal() { + let (mut child, port) = start_server(); + + // Full window: not truncated. count == total, artifacts.len() == count. + let (status, body, _headers) = fetch(port, "/api/v1/artifacts?limit=100000", false); + assert_eq!(status, 200); + let json: serde_json::Value = serde_json::from_str(&body).unwrap(); + let total_full = json["total"].as_u64().unwrap(); + let count_full = json["count"].as_u64().expect("count field required"); + let truncated_full = json["truncated"].as_bool().expect("truncated field required"); + assert_eq!( + count_full, + json["artifacts"].as_array().unwrap().len() as u64, + "count must equal artifacts.len()", + ); + assert_eq!(count_full, total_full, "full window: count must equal total"); + assert!( + !truncated_full, + "full window: truncated must be false when count == total" + ); + + // Small window: truncated. count < total, truncated: true. + let (status, body, _headers) = fetch(port, "/api/v1/artifacts?limit=1", false); + assert_eq!(status, 200); + let json: serde_json::Value = serde_json::from_str(&body).unwrap(); + let total_small = json["total"].as_u64().unwrap(); + let count_small = json["count"].as_u64().expect("count field required"); + let truncated_small = json["truncated"].as_bool().expect("truncated field required"); + assert!( + total_small > 1, + "premise: fixture must hold more than one artifact" + ); + assert_eq!(count_small, 1, "limit=1 must return exactly one artifact"); + assert!( + truncated_small, + "limit=1 on a >1-artifact fixture must report truncated: true" + ); + + child.kill().ok(); + child.wait().ok(); +} + #[test] fn api_artifacts_search() { let (mut child, port) = start_server(); @@ -843,6 +893,60 @@ fn api_diagnostics_response_shape() { child.wait().ok(); } +/// #832 / REQ-303: the diagnostics endpoint has the same `.min(1000)` cap +/// as the artifacts endpoint and needs the same truncation signal. The +/// consequence a client cares about — "the array I got back may not be the +/// full set" — is identical, so the response shape is aligned. +/// +/// rivet: verifies REQ-303 +#[test] +fn api_diagnostics_truncation_signal() { + let (mut child, port) = start_server(); + + // Full window: shape carries `count` and `truncated`, and truncated is + // false when the whole set fits. This must hold even when the fixture + // has no diagnostics (`count == total == 0`). + let (status, body, _headers) = fetch(port, "/api/v1/diagnostics?limit=100000", false); + assert_eq!(status, 200); + let json: serde_json::Value = serde_json::from_str(&body).unwrap(); + let total_full = json["total"].as_u64().unwrap(); + let count_full = json["count"].as_u64().expect("count field required"); + let truncated_full = json["truncated"].as_bool().expect("truncated field required"); + assert_eq!( + count_full, + json["diagnostics"].as_array().unwrap().len() as u64, + "count must equal diagnostics.len()" + ); + assert_eq!(count_full, total_full, "full window: count must equal total"); + assert!( + !truncated_full, + "full window: truncated must be false when count == total" + ); + + // Small window: truncated iff there is more than one diagnostic; the + // fixture doesn't guarantee any, so guard the truncation assertion on + // total > 1 rather than presuming shape. + let (status, body, _headers) = fetch(port, "/api/v1/diagnostics?limit=1", false); + assert_eq!(status, 200); + let json: serde_json::Value = serde_json::from_str(&body).unwrap(); + let total_small = json["total"].as_u64().unwrap(); + let count_small = json["count"].as_u64().unwrap(); + let truncated_small = json["truncated"].as_bool().unwrap(); + if total_small > 1 { + assert_eq!(count_small, 1, "limit=1 must return exactly one diagnostic"); + assert!( + truncated_small, + "limit=1 with total > 1 must report truncated: true" + ); + } else { + assert_eq!(count_small, total_small); + assert!(!truncated_small); + } + + child.kill().ok(); + child.wait().ok(); +} + #[test] fn api_diagnostics_filter_severity() { let (mut child, port) = start_server(); From 17e846af467400b5ef9ad4f3e1555845498f4bb8 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 12:52:23 +0000 Subject: [PATCH 2/4] style: cargo fmt on the new truncation-signal tests Wrap the `.expect(...)` calls and the `assert_eq!` label onto multiple lines so rustfmt is happy. No behavior change. Trace: skip --- rivet-cli/tests/serve_integration.rs | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/rivet-cli/tests/serve_integration.rs b/rivet-cli/tests/serve_integration.rs index d8d42b8b..e0d0371e 100644 --- a/rivet-cli/tests/serve_integration.rs +++ b/rivet-cli/tests/serve_integration.rs @@ -794,13 +794,18 @@ fn api_artifacts_truncation_signal() { let json: serde_json::Value = serde_json::from_str(&body).unwrap(); let total_full = json["total"].as_u64().unwrap(); let count_full = json["count"].as_u64().expect("count field required"); - let truncated_full = json["truncated"].as_bool().expect("truncated field required"); + let truncated_full = json["truncated"] + .as_bool() + .expect("truncated field required"); assert_eq!( count_full, json["artifacts"].as_array().unwrap().len() as u64, "count must equal artifacts.len()", ); - assert_eq!(count_full, total_full, "full window: count must equal total"); + assert_eq!( + count_full, total_full, + "full window: count must equal total" + ); assert!( !truncated_full, "full window: truncated must be false when count == total" @@ -812,7 +817,9 @@ fn api_artifacts_truncation_signal() { let json: serde_json::Value = serde_json::from_str(&body).unwrap(); let total_small = json["total"].as_u64().unwrap(); let count_small = json["count"].as_u64().expect("count field required"); - let truncated_small = json["truncated"].as_bool().expect("truncated field required"); + let truncated_small = json["truncated"] + .as_bool() + .expect("truncated field required"); assert!( total_small > 1, "premise: fixture must hold more than one artifact" @@ -911,13 +918,18 @@ fn api_diagnostics_truncation_signal() { let json: serde_json::Value = serde_json::from_str(&body).unwrap(); let total_full = json["total"].as_u64().unwrap(); let count_full = json["count"].as_u64().expect("count field required"); - let truncated_full = json["truncated"].as_bool().expect("truncated field required"); + let truncated_full = json["truncated"] + .as_bool() + .expect("truncated field required"); assert_eq!( count_full, json["diagnostics"].as_array().unwrap().len() as u64, "count must equal diagnostics.len()" ); - assert_eq!(count_full, total_full, "full window: count must equal total"); + assert_eq!( + count_full, total_full, + "full window: count must equal total" + ); assert!( !truncated_full, "full window: truncated must be false when count == total" From f4bf68588551952fd432fab79323cbd33e0b218a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 13:10:40 +0000 Subject: [PATCH 3/4] fix(test): truncation-signal tests must respect the endpoint's own cap (REQ-303, #832) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previous version asserted `count == total` on `limit=100000`, which assumed the endpoint returns everything on a large window. It doesn't: `.min(1000)` caps `limit` internally (rivet-cli/src/serve/api.rs:459 and :630) — the very defect this signal is being added to make legible. On the CI fixture (total=1024) the "full window" call therefore returned 1000 rows and the assertion failed. Restructure both `api_artifacts_truncation_signal` and `api_diagnostics_truncation_signal` to check the shape rather than a specific full-vs-partial state: - Small window (`limit=1`) forces `truncated: true` on any non-trivial fixture, and `count == 1`; assert this unconditionally for artifacts (fixture is guaranteed > 1), and branch on `total > 1` for diagnostics (fixture is not guaranteed to have any). - Full window (`limit == total`) is exercised only when `total` fits under the endpoint's `.min(1000)` cap. When it doesn't, an untruncated response is unreachable from the endpoint at all — the exact case this signal exists to make legible — so we skip that leg rather than assert an impossibility. Verified locally against the real fixture: both tests pass. Trace: skip --- rivet-cli/tests/serve_integration.rs | 138 ++++++++++++++++----------- 1 file changed, 84 insertions(+), 54 deletions(-) diff --git a/rivet-cli/tests/serve_integration.rs b/rivet-cli/tests/serve_integration.rs index e0d0371e..6cee385b 100644 --- a/rivet-cli/tests/serve_integration.rs +++ b/rivet-cli/tests/serve_integration.rs @@ -783,53 +783,70 @@ fn api_artifacts_pagination() { /// return the full set. Otherwise every consumer reading `artifacts` as the /// full set is silently wrong. /// +/// The endpoint caps `limit` at 1000 internally, so on a fixture whose +/// `total` exceeds the cap the endpoint cannot return an untruncated +/// window at all — which is *precisely why* this signal exists. The test +/// asserts the shape rather than the specific full-vs-partial state: +/// truncated iff count < total, and count == artifacts.len(), on both a +/// small window (forces truncated=true) and, when the fixture fits, a +/// window that covers the whole store (verifies truncated=false). +/// /// rivet: verifies REQ-303 #[test] fn api_artifacts_truncation_signal() { let (mut child, port) = start_server(); - // Full window: not truncated. count == total, artifacts.len() == count. - let (status, body, _headers) = fetch(port, "/api/v1/artifacts?limit=100000", false); - assert_eq!(status, 200); - let json: serde_json::Value = serde_json::from_str(&body).unwrap(); - let total_full = json["total"].as_u64().unwrap(); - let count_full = json["count"].as_u64().expect("count field required"); - let truncated_full = json["truncated"] - .as_bool() - .expect("truncated field required"); - assert_eq!( - count_full, - json["artifacts"].as_array().unwrap().len() as u64, - "count must equal artifacts.len()", - ); - assert_eq!( - count_full, total_full, - "full window: count must equal total" - ); - assert!( - !truncated_full, - "full window: truncated must be false when count == total" - ); - - // Small window: truncated. count < total, truncated: true. + // Small window: truncated. count == 1, truncated: true. let (status, body, _headers) = fetch(port, "/api/v1/artifacts?limit=1", false); assert_eq!(status, 200); let json: serde_json::Value = serde_json::from_str(&body).unwrap(); - let total_small = json["total"].as_u64().unwrap(); + let total = json["total"].as_u64().unwrap(); let count_small = json["count"].as_u64().expect("count field required"); let truncated_small = json["truncated"] .as_bool() .expect("truncated field required"); assert!( - total_small > 1, + total > 1, "premise: fixture must hold more than one artifact" ); + assert_eq!( + count_small, + json["artifacts"].as_array().unwrap().len() as u64, + "count must equal artifacts.len()", + ); assert_eq!(count_small, 1, "limit=1 must return exactly one artifact"); assert!( truncated_small, "limit=1 on a >1-artifact fixture must report truncated: true" ); + // Full window: only when the fixture fits under the endpoint's + // internal `.min(1000)` cap. When it doesn't, an untruncated response + // is unreachable from this endpoint — exactly the state this signal + // exists to make legible — so we skip this leg rather than assert an + // impossibility. + const ENDPOINT_LIMIT_CAP: u64 = 1000; + if total <= ENDPOINT_LIMIT_CAP { + let url = format!("/api/v1/artifacts?limit={total}"); + let (status, body, _headers) = fetch(port, &url, false); + assert_eq!(status, 200); + let json: serde_json::Value = serde_json::from_str(&body).unwrap(); + let count_full = json["count"].as_u64().expect("count field required"); + let truncated_full = json["truncated"] + .as_bool() + .expect("truncated field required"); + assert_eq!( + count_full, + json["artifacts"].as_array().unwrap().len() as u64, + "count must equal artifacts.len()", + ); + assert_eq!(count_full, total, "full window: count must equal total"); + assert!( + !truncated_full, + "full window: truncated must be false when count == total" + ); + } + child.kill().ok(); child.wait().ok(); } @@ -903,58 +920,71 @@ fn api_diagnostics_response_shape() { /// #832 / REQ-303: the diagnostics endpoint has the same `.min(1000)` cap /// as the artifacts endpoint and needs the same truncation signal. The /// consequence a client cares about — "the array I got back may not be the -/// full set" — is identical, so the response shape is aligned. +/// full set" — is identical, so the response shape is aligned. Same +/// full-window caveat as `api_artifacts_truncation_signal`: when `total` +/// exceeds the endpoint's cap, an untruncated response is unreachable +/// and the "full window" leg is skipped. /// /// rivet: verifies REQ-303 #[test] fn api_diagnostics_truncation_signal() { let (mut child, port) = start_server(); - // Full window: shape carries `count` and `truncated`, and truncated is - // false when the whole set fits. This must hold even when the fixture - // has no diagnostics (`count == total == 0`). - let (status, body, _headers) = fetch(port, "/api/v1/diagnostics?limit=100000", false); + // First call: discover the fixture's `total` and check shape. + let (status, body, _headers) = fetch(port, "/api/v1/diagnostics?limit=1", false); assert_eq!(status, 200); let json: serde_json::Value = serde_json::from_str(&body).unwrap(); - let total_full = json["total"].as_u64().unwrap(); - let count_full = json["count"].as_u64().expect("count field required"); - let truncated_full = json["truncated"] + let total = json["total"].as_u64().unwrap(); + let count_small = json["count"].as_u64().expect("count field required"); + let truncated_small = json["truncated"] .as_bool() .expect("truncated field required"); assert_eq!( - count_full, + count_small, json["diagnostics"].as_array().unwrap().len() as u64, "count must equal diagnostics.len()" ); - assert_eq!( - count_full, total_full, - "full window: count must equal total" - ); - assert!( - !truncated_full, - "full window: truncated must be false when count == total" - ); - // Small window: truncated iff there is more than one diagnostic; the - // fixture doesn't guarantee any, so guard the truncation assertion on - // total > 1 rather than presuming shape. - let (status, body, _headers) = fetch(port, "/api/v1/diagnostics?limit=1", false); - assert_eq!(status, 200); - let json: serde_json::Value = serde_json::from_str(&body).unwrap(); - let total_small = json["total"].as_u64().unwrap(); - let count_small = json["count"].as_u64().unwrap(); - let truncated_small = json["truncated"].as_bool().unwrap(); - if total_small > 1 { + // The fixture doesn't guarantee any diagnostics, so branch on `total` + // rather than presume shape. + if total > 1 { assert_eq!(count_small, 1, "limit=1 must return exactly one diagnostic"); assert!( truncated_small, "limit=1 with total > 1 must report truncated: true" ); } else { - assert_eq!(count_small, total_small); + assert_eq!(count_small, total); assert!(!truncated_small); } + // Full window: only when the fixture fits under the endpoint's cap. + const ENDPOINT_LIMIT_CAP: u64 = 1000; + if total <= ENDPOINT_LIMIT_CAP { + let url = if total == 0 { + "/api/v1/diagnostics".to_string() + } else { + format!("/api/v1/diagnostics?limit={total}") + }; + let (status, body, _headers) = fetch(port, &url, false); + assert_eq!(status, 200); + let json: serde_json::Value = serde_json::from_str(&body).unwrap(); + let count_full = json["count"].as_u64().expect("count field required"); + let truncated_full = json["truncated"] + .as_bool() + .expect("truncated field required"); + assert_eq!( + count_full, + json["diagnostics"].as_array().unwrap().len() as u64, + "count must equal diagnostics.len()" + ); + assert_eq!(count_full, total, "full window: count must equal total"); + assert!( + !truncated_full, + "full window: truncated must be false when count == total" + ); + } + child.kill().ok(); child.wait().ok(); } From d156713c99b2750ed9b23604f1f7396c76270b6c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 13:14:55 +0000 Subject: [PATCH 4/4] fix(proofs): port Kani CoverageEntry init fix from #865 Kani Proofs on this PR's head is red for a compile error in code the diff doesn't touch (rivet-core/src/proofs.rs:216, missing exempt / exempt_ids fields added by REQ-309 / #848 to `CoverageEntry`). The fix is already up at #865. Ported here so this PR's Kani stops being red on an unrelated failure; no-ops once #865 merges. Fixes: REQ-309 Refs: #848, #865 --- rivet-core/src/proofs.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/rivet-core/src/proofs.rs b/rivet-core/src/proofs.rs index b65ead63..40c588e5 100644 --- a/rivet-core/src/proofs.rs +++ b/rivet-core/src/proofs.rs @@ -221,6 +221,8 @@ mod proofs { direction: crate::coverage::CoverageDirection::Forward, target_types: vec![], covered, + exempt: 0, + exempt_ids: vec![], total, uncovered_ids: vec![], external_boundary: 0,