diff --git a/src/api.rs b/src/api.rs index ff77c6e9..6d33c7cd 100644 --- a/src/api.rs +++ b/src/api.rs @@ -1307,7 +1307,7 @@ fn access_error( access_error_noted(state, failure, name, "", started_at) } -/// [`access_error`] with a leading `note` — the one place the three +/// [`access_error`] with a leading `note` — the one place the five /// `AccessError` arms map to statuses and metrics, so the per-batch /// import path (which prefixes each refusal with which batch failed) /// shares them instead of hand-copying the mapping. `note` is empty @@ -2103,9 +2103,10 @@ fn recollections_out( /// [`associations_out`] for a cross-context page: section/locator /// markers resolve against the context each match came from — one /// `resolve_markers` call per distinct context on the page, not one -/// per match (and none for a context whose page entries carry no -/// paragraph locator; `resolve_markers` short-circuits on an empty -/// key set). +/// per match. A context whose page entries carry no paragraph locator +/// still gets called (every distinct context is registered in +/// `locators` up front), but `resolve_markers` short-circuits on the +/// resulting empty key set, so that call costs nothing. fn cross_associations_out( state: &AppState, page: Vec<(String, Association)>, diff --git a/src/api/consolidation.rs b/src/api/consolidation.rs index ccaa647d..c4e4c69a 100644 --- a/src/api/consolidation.rs +++ b/src/api/consolidation.rs @@ -2,8 +2,10 @@ //! contradiction, and staleness candidates in one response, sections //! selected by the caller's `checks` — every candidate carrying a //! content fingerprint over its own evidence, because the fingerprint -//! is the judgment artifact's identity and its staleness mechanism at -//! once (ADR 0012 §5). Candidates for review, never verdicts: nothing +//! stands in for the judgment artifact's identity and doubles as its +//! staleness mechanism (ADR 0012 §5) — a 64-bit FNV-1a digest, so a +//! collision could in principle reuse a stale judgment, not a +//! collision-resistant hash. Candidates for review, never verdicts: nothing //! here applies anything, and the server never judges — the judging //! client reuses stored judgments for unchanged fingerprints and pays //! an LLM only for what moved. diff --git a/src/api/explore.rs b/src/api/explore.rs index cfb3c792..f19e54e0 100644 --- a/src/api/explore.rs +++ b/src/api/explore.rs @@ -146,6 +146,10 @@ pub async fn explore( } } +/// Unlike [`ExploreRequest`]/`ActivateRequest`, this has no +/// `since`/`until` — ADR 0011's assertion-time window was never +/// extended to `paths`. Not a deliberate exclusion, just unconsidered: +/// nothing here rules a window out. #[derive(Debug, Deserialize)] pub struct PathsRequest { pub origins: Vec, diff --git a/src/api/import.rs b/src/api/import.rs index ab489bfd..551a11f8 100644 --- a/src/api/import.rs +++ b/src/api/import.rs @@ -270,7 +270,7 @@ pub(super) fn import_refusal( started_at: Instant, ) -> Response { match refusal { - // The three AccessError arms — status, metric, message — live + // The five AccessError arms — status, metric, message — live // in access_error_noted; import just supplies the batch note. crate::ingest::ApplyRefusal::Access(failure) => { access_error_noted(state, failure, &batch.context, note, started_at) @@ -1176,8 +1176,11 @@ pub async fn import_batch( /// `POST /contexts/{name}/compact` — rebuild the image without the /// dead weight the append-only format accumulates (retracted edges, -/// unlinked attributions, arena slack), persisting the result before -/// answering. An admin verb (the role table's fail-closed default); +/// unlinked attributions, arena slack), then try to persist the +/// result before answering — a flush that can't publish still leaves +/// the graph compacted in memory, so the call still answers 200; see +/// `image_persisted` on the response for whether the rebuild actually +/// reached disk. An admin verb (the role table's fail-closed default); /// the context's own requests wait out the rebuild, every other /// context is untouched. Content is preserved — the response says /// what was shed and what the footprint became. diff --git a/src/api/promote.rs b/src/api/promote.rs index fced1b04..4c425503 100644 --- a/src/api/promote.rs +++ b/src/api/promote.rs @@ -517,13 +517,12 @@ const QUOTA_NEXT_STEP: (&str, &str) = ( /// The destination-over-quota refusal, `/import`'s own batch-granular /// pre-check report. Every promote batch targets ONE destination and -/// always carries growth, so the deterministic firing shape is batch 1 -/// against a destination already over its ceiling (`landed` 0, -/// `nothing_written` — tested); the `durable_prefix` shape needs the -/// stream itself to cross the ceiling mid-loop, a live-lane timing no -/// test can pin. -#[mutants::skip] -// the durable>0 half of stream_integrity's output is reachable only via mid-stream timing; the landed==0 half is asserted in tests +/// always carries growth: the `nothing_written` shape fires when the +/// destination is already over its ceiling before batch 1 (tested), +/// and the `durable_prefix` shape fires when an earlier batch's own +/// growth tips the ceiling for the batch after it (also tested) — +/// `storage_quota_refusal` reads only the destination's current disk +/// usage, never wall-clock time, so both shapes are deterministic. #[allow(clippy::too_many_arguments)] fn quota_refusal( index: usize, diff --git a/src/api/sources.rs b/src/api/sources.rs index e1a6ea66..96fbff8b 100644 --- a/src/api/sources.rs +++ b/src/api/sources.rs @@ -1627,12 +1627,14 @@ pub struct CrossSearchPassagesRequest { /// same rank-fusion posture the endpoint already takes across its two /// lanes. `score` stays what it was, per-context evidence. Every /// target's search runs concurrently, bounded by -/// [`cross_search_concurrency`] — on a stone-cold cache, up to that -/// many targets may each pay for the query embedding before the first -/// resolution lands in the cue cache, instead of exactly one target -/// paying and the rest reusing it; the cache is still the single -/// source of truth (a `Mutex`), so this is wasted provider calls, not -/// a correctness risk, and every request after the first is unaffected. +/// [`cross_search_concurrency`] — with the retrieval cache enabled (the +/// default), a probe warms the cue cache before the fan-out starts, so +/// only that probe pays for the query embedding. With the cache +/// disabled, there is no probe to warm anything, so up to that many +/// targets may each pay for the query embedding independently; the +/// cache is still the single source of truth when it exists (a +/// `Mutex`), so even the disabled-cache case is wasted provider calls, +/// not a correctness risk. pub async fn cross_search_passages( State(state): State, scope: Option>, diff --git a/src/context/consolidation.rs b/src/context/consolidation.rs index cace8f2f..8761456d 100644 --- a/src/context/consolidation.rs +++ b/src/context/consolidation.rs @@ -44,8 +44,9 @@ pub struct MergeEvidence { pub only_a: Vec, pub only_b: Vec, /// FNV-1a over the pair's names and its FULL evidence sets - /// (never the capped lists), pair-order canonicalized — the - /// judgment artifact's identity (ADR 0012 §5). Serialized by the + /// (never the capped lists), pair-order canonicalized — stands in + /// for the judgment artifact's identity (ADR 0012 §5); a 64-bit + /// digest, not a collision-resistant hash. Serialized by the /// audit's own wire shape, not here. #[serde(skip)] pub fingerprint: u64, diff --git a/src/llm-protocol.md b/src/llm-protocol.md index 744107aa..ab918f4f 100644 --- a/src/llm-protocol.md +++ b/src/llm-protocol.md @@ -448,7 +448,9 @@ retry) / `storage_full` / `read_only_replica` (403: this server is a read replica — do NOT retry here; send the write to the writer the message names) / `shard_unreachable` (502 from a `taguru router`: a shard this request needs did not answer — retry once the shard or its load -balancer does). +balancer does) / `stale_cursor` (410: a `/changes` cursor outlived the +in-memory event ring — resync fully, then tail again from a fresh +cursor). **Rejected `add_associations`, `store_passages`, and `import` calls carry structured detail** (additive fields, present only where they apply — diff --git a/tests/http_api/promote.rs b/tests/http_api/promote.rs index 26de6a3c..546595e8 100644 --- a/tests/http_api/promote.rs +++ b/tests/http_api/promote.rs @@ -526,6 +526,89 @@ fn the_destination_quota_gates_growth_before_the_batch_is_attempted() { assert!(message.contains("storage quota"), "{message}"); } +/// Sums the five on-disk lanes `AppState::storage_quota_excess` gates +/// on (`src/registry/engine.rs`) straight off `/metrics`, requiring +/// `TAGURU_METRICS_PER_CONTEXT`. +fn disk_total_bytes(server: &Server, context: &str) -> u64 { + let (status, body) = server.call("GET", "/metrics", None); + assert_eq!(status, 200); + let text = body.as_str().expect("metrics body is text, not JSON"); + ["image", "passages", "passages_wal", "sidecars", "wal"] + .iter() + .map(|file| { + let prefix = + format!("taguru_context_disk_bytes{{context=\"{context}\",file=\"{file}\"}} "); + text.lines() + .find_map(|line| line.strip_prefix(prefix.as_str())) + .and_then(|value| value.trim().parse::().ok()) + .unwrap_or(0) + }) + .sum() +} + +/// The other half of the ceiling test above: when the destination +/// clears its ceiling for the FIRST promoted batch but that batch's +/// own growth tips it over, the SECOND batch's refusal reports a +/// durable prefix — `quota_refusal`'s `landed > 0` shape, unreachable +/// from the `nothing_written` path both this file's earlier test and +/// `quotas.rs` already cover. `storage_quota_refusal` is a pure +/// function of the destination's CURRENT disk usage, never wall-clock +/// time, so the boundary is fully deterministic: measure what landing +/// batch 0 alone costs on an uncapped probe server, then reproduce the +/// identical scenario on a fresh server whose ceiling sits exactly at +/// that measured usage. +#[test] +fn quota_refusal_reports_a_durable_prefix_when_the_first_landed_batch_tips_the_ceiling() { + let probe = Server::start_with_env( + "promote-quota-probe", + &[("TAGURU_METRICS_PER_CONTEXT", "1")], + ); + seed(&probe); + probe.ok( + "POST", + "/contexts/scratch-claude/promote", + Some(json!({"into": "perm", "sources": ["session:claude:a/note"]})), + ); + let ceiling = disk_total_bytes(&probe, "perm"); + assert!( + ceiling > 0, + "the landed batch must have grown the destination" + ); + + let quotas = format!(r#"{{"perm": {{"storage_bytes": {ceiling}, "cache_bytes": 1048576}}}}"#); + let server = Server::start_with_env( + "promote-quota-durable", + &[("TAGURU_CONTEXT_QUOTAS", quotas.as_str())], + ); + seed(&server); + + let (status, refused) = server.call( + "POST", + "/contexts/scratch-claude/promote", + Some(json!({ + "into": "perm", + "sources": ["session:claude:a/note", "session:claude:b"] + })), + ); + assert_eq!(status, 507, "{refused}"); + assert_eq!(refused["code"], json!("storage_full"), "{refused}"); + assert_eq!(refused["integrity"], json!("durable_prefix"), "{refused}"); + assert_eq!(refused["durable_batches"], json!(1), "{refused}"); + let message = refused["error"].as_str().unwrap(); + assert!(message.contains("batch 2 of 2"), "{message}"); + assert!(message.contains("storage quota"), "{message}"); + + // The first batch landed for real before the second was refused — + // specifically batch 0's source, not just any one source. + let sources = server.ok("GET", "/contexts/perm/sources", None); + assert_eq!(sources["total"], json!(1), "{sources}"); + assert_eq!( + sources["sources"], + json!(["session:claude:a/note"]), + "{sources}" + ); +} + /// A `warn`-mode destination schema lets the promoted batches land and /// reports the violations in the success envelope, `/import`'s own /// accounting — the exact true count, not a truncation artifact.