Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)>,
Expand Down
6 changes: 4 additions & 2 deletions src/api/consolidation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions src/api/explore.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
Expand Down
9 changes: 6 additions & 3 deletions src/api/import.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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.
Expand Down
13 changes: 6 additions & 7 deletions src/api/promote.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
14 changes: 8 additions & 6 deletions src/api/sources.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<AppState>,
scope: Option<axum::Extension<crate::auth::KeyScope>>,
Expand Down
5 changes: 3 additions & 2 deletions src/context/consolidation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,9 @@ pub struct MergeEvidence {
pub only_a: Vec<NeighborFact>,
pub only_b: Vec<NeighborFact>,
/// 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,
Expand Down
4 changes: 3 additions & 1 deletion src/llm-protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 —
Expand Down
77 changes: 77 additions & 0 deletions tests/http_api/promote.rs
Original file line number Diff line number Diff line change
Expand Up @@ -526,6 +526,83 @@ 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::<u64>().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.
let sources = server.ok("GET", "/contexts/perm/sources", None);
assert_eq!(sources["total"], json!(1), "{sources}");
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

/// 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.
Expand Down