Skip to content
Draft
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
14 changes: 14 additions & 0 deletions batten.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5095,6 +5095,20 @@ severity = "deny"
[[rule.captured]]
id = "this-row"
key = "CLOUD-1188"
# THE KEY IS THE RECORD'S SUBJECT, NOT A STRING IN IT (CLOUD-1387).
#
# Without this path, selection is byte containment over the whole response and
# the first match in handle order answers — so every stored document that merely
# CITES the key competes, and a digest decides between them. Measured 2026-09-03
# over this repository's own store: 14 captures contained `CLOUD-1188`, the one
# read carried no `project` node and answered `false`, and the `get_issue` for
# this row — carrying `project` — sorted later and was never consulted. A filed
# row reported unfiled, from a payload that was never about it, and the count
# went 0 -> 1 from ordinary board reads with no tree or config change.
#
# `id` is THIS TRACKER's spelling and belongs here rather than in the engine,
# which is what keeps non-negotiable rule 1 paid while the defect is closed.
key_at = "id"
node = "project"
reduce = "present"

Expand Down
41 changes: 34 additions & 7 deletions crates/batten/src/capture.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1926,17 +1926,44 @@ pub fn find(repo_root: &Path, selector: &Selector<'_>) -> Result<Option<Resolved
///
/// As [`find`].
pub fn find_in(dir: &Path, selector: &Selector<'_>) -> Result<Option<Resolved>> {
Ok(find_in_filtered(dir, selector, true))
}

/// [`find`] with the TOOL filter dropped: the key and its path select alone.
///
/// For a caller that names a key and a path and legitimately names no tool —
/// [`crate::captured::reduce`]'s declared rows (CLOUD-1387). It is a separate
/// entry point rather than "empty `tools` means any", because `Selector::tools`
/// is matched with `any` and an empty slice therefore already means *no tool
/// matches*; quietly inverting that would change what every existing caller's
/// empty list does.
///
/// # Errors
///
/// As [`find`].
pub fn find_any_tool(repo_root: &Path, selector: &Selector<'_>) -> Result<Option<Resolved>> {
Ok(find_in_filtered(&captures_dir(repo_root)?, selector, false))
}

/// The one walk both entry points share, so the ordering has a single authority.
///
/// Infallible by construction: a call log that cannot be read is an empty log,
/// and a row whose blob has been pruned is skipped. Both are ordinary states of
/// a store rather than a failure to look, so there is no error to report and the
/// wrappers above supply the `Ok` their published signatures promise.
fn find_in_filtered(dir: &Path, selector: &Selector<'_>, by_tool: bool) -> Option<Resolved> {
for row in read_calls(&dir.join("calls")).iter().rev() {
let Some(digest) = row.digest.as_deref() else {
continue;
};
if !token_is_complete(&row.fidelity) {
continue;
}
if !selector
.tools
.iter()
.any(|tool| crate::rules::selects_tool_name(tool, &row.tool))
if by_tool
&& !selector
.tools
.iter()
.any(|tool| crate::rules::selects_tool_name(tool, &row.tool))
{
continue;
}
Expand All @@ -1953,17 +1980,17 @@ pub fn find_in(dir: &Path, selector: &Selector<'_>) -> Result<Option<Resolved>>
if crate::mint::scalar(&value, selector.key_at).as_deref() != Some(selector.key) {
continue;
}
return Ok(Some(Resolved {
return Some(Resolved {
capture: Capture {
stream: Stream::Response.as_str(),
bytes: bytes.len() as u64,
digest: digest.to_owned(),
},
tool: row.tool.clone(),
order: row.order,
}));
});
}
Ok(None)
None
}

/// Remove every capture in the repository's store, returning how many went.
Expand Down
100 changes: 83 additions & 17 deletions crates/batten/src/captured.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,33 @@ use std::path::Path;

use crate::facts::{CaptureQuery, Format};

/// The most recent captured response whose scalar at `key_at` equals `key`.
///
/// [`crate::capture::find`]'s question with the tool filter removed, because a
/// `[[rule.captured]]` row names a key and a path and never a tool — see the
/// call site for why fabricating a tool list would be worse than omitting the
/// filter.
///
/// **Append order, taken from the end**, which is [`crate::capture::find_in`]'s
/// ordering and is chosen for its reason rather than copied: `order` is monotone
/// only WITHIN a session, so sorting by it lets a stale session outrank a live
/// one, while the log's append order is chronological across all of them and is
/// still a pure function of the log's bytes. So recency costs no clock and two
/// runs over an unchanged store agree.
///
/// Returns the response's text, so the caller parses it through the crate's one
/// [`crate::rules::parse_node`] call site rather than through a second mapping.
fn find_by_key_at(root: &Path, key: &str, key_at: &str) -> Option<String> {
let selector = crate::capture::Selector {
tools: &[],
key,
key_at,
};
let resolved = crate::capture::find_any_tool(root, &selector).ok()??;
let bytes = crate::capture::read(root, &resolved.capture).ok()?;
String::from_utf8(bytes).ok()
}

/// The stream a captured RESPONSE is filed under.
///
/// Responses only: a captured command line or its stdout is not a payload
Expand All @@ -60,10 +87,22 @@ const RESPONSES: &str = "response";

/// Reduce each DECLARED row against the capture store.
///
/// **First match in HANDLE order**, which is [`crate::capture::list`]'s own sort,
/// so two runs over an unchanged store return the same answer — the byte
/// stability `Surface::Check` requires and the property a time-ordered store
/// could not offer.
/// **How a row selects depends on whether it declared `key_at`**, and the two
/// arms answer different questions (CLOUD-1387).
///
/// With a path, the row resolves through [`crate::capture::find`]: the response
/// whose scalar at that path EQUALS the key, most recent first in the log's
/// append order. That is the record the key is the subject OF.
///
/// Without one, selection is byte containment and the **first match in HANDLE
/// order** answers — [`crate::capture::list`]'s own sort. That is every document
/// that MENTIONS the key, with a digest deciding between them, and it is why
/// `key_at` exists; it stays the default only so a landed row does not change
/// verdict underneath a consumer.
///
/// Both arms are byte-stable, which is what `Surface::Check` requires: handle
/// order is a sort, and append order is a pure function of the log's bytes. A
/// time-ordered store could offer neither.
///
/// **An id whose key nothing matched is ABSENT** from the result, never present
/// with a falsy value: "nothing has been captured about this" and "the capture
Expand Down Expand Up @@ -116,19 +155,46 @@ pub fn reduce(

let mut found = BTreeMap::new();
for row in declared {
// The KEY selects the capture, by containment in the response's own
// bytes. Containment rather than a parsed field, because which member
// carries a key is a tracker's schema and non-negotiable rule 1 keeps
// that out of this crate — the row names the token, the engine matches
// it.
let Some(node) = parsed
.iter()
.find(|(text, node)| text.contains(&row.key) && node.is_some())
.and_then(|(_, node)| node.as_ref())
else {
// NOTHING HAS BEEN CAPTURED about this key, or what was captured did
// not parse. Absent, never a falsy answer.
continue;
// A DECLARED PATH SELECTS THE RECORD THE KEY IS THE SUBJECT OF, through
// the same resolver `capture find --key-at` uses. One authority on what
// "the capture for this key" means, rather than two that can disagree.
//
// Rule 1 is intact either way: the path is the ROW's, so no tracker field
// name reaches this crate — the engine reads what it was handed, exactly
// as it does for `node`.
let owned;
let node = if let Some(key_at) = row.key_at.as_deref() {
// No tool filter: a `[[rule.captured]]` row names a key and a path,
// never a tool, and inventing a default here would silently exclude
// whichever tool a consumer's response came from. `Selector`'s tools
// are matched with `any`, so an empty slice is "no tool matches" —
// hence the dedicated resolver below rather than a `find` call with
// a fabricated list.
let Some(text) = find_by_key_at(root, &row.key, key_at) else {
// NOTHING CAPTURED CARRIES THIS KEY AT THIS PATH. Absent, never
// a falsy answer — the could-not-look arm the module reads.
continue;
};
let Ok(parsed) = crate::rules::parse_node(Format::Json, &text) else {
continue;
};
owned = parsed;
&owned
} else {
// THE LEGACY ARM: containment over the response's own bytes, first
// match in handle order. Kept so a row that declared no path does
// not change verdict, and no longer the recommended shape — see
// `CaptureQuery::key_at` for what it costs (CLOUD-1387).
let Some(node) = parsed
.iter()
.find(|(text, node)| text.contains(&row.key) && node.is_some())
.and_then(|(_, node)| node.as_ref())
else {
// Nothing captured about this key, or what was captured did not
// parse. Absent, never a falsy answer.
continue;
};
node
};
if let Some(value) = row.reduce.apply(&node.at(&row.node)) {
found.insert(row.id.clone(), value);
Expand Down
36 changes: 34 additions & 2 deletions crates/batten/src/facts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2894,10 +2894,42 @@ pub struct CaptureQuery {
/// The token that selects which captured response answers.
///
/// An opaque string the consumer supplies. The engine knows nothing about
/// what it names — it matches captures containing it and reduces the first in
/// handle order, which is where non-negotiable rule 1 is paid: a tracker's
/// what it names, which is where non-negotiable rule 1 is paid: a tracker's
/// key vocabulary is the consumer's fact and never this crate's.
///
/// How it SELECTS depends on [`Self::key_at`], and the two are different
/// questions — see that field.
pub key: String,
/// Where the key sits in the response, in [`Node::at`]'s spelling.
///
/// # This is the difference between "the record about the key" and "a record mentioning it"
///
/// Without it, selection is byte CONTAINMENT over the response and the first
/// match in handle order answers — so any stored document that merely cites
/// the key competes, and which one wins is decided by a digest. Measured
/// 2026-09-03 over this repository's own store (CLOUD-1387): 14 captures
/// contained `CLOUD-1188`, the one read carried no `project` node and
/// answered `false`, and the `get_issue` for that row — carrying
/// `project: "Batten"` — sorted later and was never consulted. A filed row
/// was reported unfiled, from a payload that was never about it.
///
/// With it, the row is resolved through [`crate::capture::find`], which
/// selects a response whose scalar AT THIS PATH equals the key and takes the
/// most recent in the log's append order. That is the same selector
/// `capture find --key-at` already exposes, so there is one authority on what
/// "the capture for this key" means rather than two.
///
/// **Optional, and absent keeps the old meaning rather than changing a
/// verdict silently.** A row that declares no path is still resolved by
/// containment: this field can only ever narrow what answers, so adding it is
/// raise-only in house-style §8's sense, and a consumer's landed rows do not
/// move underneath them.
///
/// **The PATH is the consumer's, which is what keeps rule 1 intact.** The
/// engine never names `id`, or any other tracker field — it reads the path
/// the row supplies, exactly as `node` below is read.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub key_at: Option<String>,
/// The node path inside the selected response, in [`Node::at`]'s spelling.
pub node: String,
/// What to make of the node the path reaches.
Expand Down
Loading