Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
83 changes: 83 additions & 0 deletions tests/http_api/promote.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<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 —
// 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.
Expand Down