diff --git a/batten.toml b/batten.toml index 41e1a2879..e5222ac61 100644 --- a/batten.toml +++ b/batten.toml @@ -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" diff --git a/crates/batten/src/capture.rs b/crates/batten/src/capture.rs index 3573abc42..3489f9f55 100644 --- a/crates/batten/src/capture.rs +++ b/crates/batten/src/capture.rs @@ -1926,6 +1926,32 @@ pub fn find(repo_root: &Path, selector: &Selector<'_>) -> Result) -> Result> { + 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> { + 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 { for row in read_calls(&dir.join("calls")).iter().rev() { let Some(digest) = row.digest.as_deref() else { continue; @@ -1933,10 +1959,11 @@ pub fn find_in(dir: &Path, selector: &Selector<'_>) -> Result> 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; } @@ -1953,7 +1980,7 @@ pub fn find_in(dir: &Path, selector: &Selector<'_>) -> Result> 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, @@ -1961,9 +1988,9 @@ pub fn find_in(dir: &Path, selector: &Selector<'_>) -> Result> }, tool: row.tool.clone(), order: row.order, - })); + }); } - Ok(None) + None } /// Remove every capture in the repository's store, returning how many went. diff --git a/crates/batten/src/captured.rs b/crates/batten/src/captured.rs index 5ca31b3c1..3cb6c18ab 100644 --- a/crates/batten/src/captured.rs +++ b/crates/batten/src/captured.rs @@ -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 { + 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 @@ -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 @@ -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); diff --git a/crates/batten/src/facts.rs b/crates/batten/src/facts.rs index 5aae9d969..58113148b 100644 --- a/crates/batten/src/facts.rs +++ b/crates/batten/src/facts.rs @@ -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, /// The node path inside the selected response, in [`Node::at`]'s spelling. pub node: String, /// What to make of the node the path reaches. diff --git a/crates/batten/tests/it/captured_facts.rs b/crates/batten/tests/it/captured_facts.rs index 81f0e4da3..800d767ee 100644 --- a/crates/batten/tests/it/captured_facts.rs +++ b/crates/batten/tests/it/captured_facts.rs @@ -342,3 +342,196 @@ fn two_runs_over_an_unchanged_store_agree() { "two runs over an unchanged store must be byte-identical" ); } + +// --- CLOUD-1387: the key is a subject, not a substring ------------------------- + +/// The row that reads through `key_at`, and its probe's two classes. +/// +/// One reduction, two verdict classes keyed to the two ANSWERS — `unstarted` +/// from the record whose `id` is the key, `blocked` from the one that merely +/// cites it. Reading the answer rather than a bare "did it fire" is what makes +/// this discriminate: a case asserting only that something fired passes on +/// either document. +fn subject_config() -> String { + format!( + r#"version = 1 + +[[rule]] +id = "probe" +kind = "policy" +scope = "tree" +module = "probe.rego" +severity = "deny" + +[[rule.captured]] +id = "state" +key = "{DECLARED_KEY}" +key_at = "id" +node = "status" +reduce = "token" + +[[verdict]] +id = "captured subject probe" +gloss = "the reduction answered from the record the key is the subject of" +class = "A fixture class, raised only by this suite's probe module." + +[[verdict.route]] +id = "probe subject probe" +kind = "document" +target = "probe.rego" + +[[verdict]] +id = "captured mention probe" +gloss = "the reduction answered from a record that merely cites the key" +class = "A fixture class, raised only by this suite's probe module." + +[[verdict.route]] +id = "probe mention probe" +kind = "document" +target = "probe.rego" +"# + ) +} + +const SUBJECT_PROBE: &str = r#"package batten.probe + +import rego.v1 + +rules contains "probe-subject" + +rules contains "probe-mention" + +violation contains { + "rule": "probe-subject", + "verdict": "captured subject probe", +} if { + is_object(input.tree.captured) + input.tree.captured.state == "unstarted" +} + +violation contains { + "rule": "probe-mention", + "verdict": "captured mention probe", +} if { + is_object(input.tree.captured) + input.tree.captured.state == "blocked" +} + +test_the_subjects_answer_fires if { + some v in violation with input as {"tree": {"captured": {"state": "unstarted"}}} + v.rule == "probe-subject" +} + +test_the_mentions_answer_fires_the_other_class if { + some v in violation with input as {"tree": {"captured": {"state": "blocked"}}} + v.rule == "probe-mention" +} +"#; + +/// Store one response and record the call row that makes it findable. +/// +/// Both halves, because they answer different questions and the selector reads +/// the second: `store_in` writes the blob, and the call log is what carries the +/// tool, the fidelity and the append order. A fixture writing only the blob +/// leaves a store no `find` can resolve — which is a shape no consumer produces, +/// since `mcp call` always writes both. +fn store_call(store: &Path, tool: &str, body: &str) -> String { + let capture = + batten::capture::store_in(store, batten::capture::Stream::Response, body.as_bytes()) + .expect("store the response"); + batten::capture::record_call_in( + store, + &batten::capture::CallRow { + order: 0, + session: "fixture".to_owned(), + source: "mcp".to_owned(), + host: "claude-code".to_owned(), + tool: tool.to_owned(), + event: "PostToolUse".to_owned(), + fidelity: batten::capture::Fidelity::LexicalBytes.as_str().to_owned(), + seen_at: None, + class: None, + digest: Some(capture.digest.clone()), + absent: None, + }, + ) + .expect("record the call"); + capture.digest +} + +#[test] +fn a_mentioning_document_does_not_answer_for_the_key() { + // THE DEFECT CLOUD-1387 RECORDS, as a case. Selection was byte containment + // over the whole response, first match in handle order — so any document + // CITING the key competed and a digest decided between them. Measured over + // this repository's own store: 14 captures contained `CLOUD-1188`, the one + // read carried no `project` node, and the record that key was the subject of + // sorted later and was never consulted. + // + // THE PREMISE IS ESTABLISHED, NOT ASSUMED (CLOUD-249). Handle order is + // digest order, so "the mentioning document sorts first" is a property of + // the bytes rather than of the writing order. A nonce is searched until it + // holds and the search is asserted, because a case that merely hoped for it + // would pass under the old code whenever the coin landed the other way — and + // a test that cannot discriminate is the thing CLOUD-418 is about. + let dir = scratch("captured-subject"); + let home = scratch("captured-subject-home"); + write(&dir, "batten.toml", &subject_config()); + write(&dir, "probe.rego", SUBJECT_PROBE); + git_in(&dir, &["init", "-q", "-b", "main", "."]); + + let store = home + .join("data") + .join(env!("CARGO_PKG_NAME")) + .join(batten::state::derive_repo_name(&dir).expect("derive the repo state segment")) + .join("captures"); + std::fs::create_dir_all(&store).expect("create the capture store"); + + // The record the key IS the subject of. + let subject = serde_json::json!({"id": DECLARED_KEY, "status": "unstarted"}).to_string(); + let subject_digest = store_call(&store, "get_issue", &subject); + + // A record that merely CITES the key, under a different id and a different + // status — so which document answered is readable from the verdict. + let mut mention = String::new(); + let mut sorts_first = false; + for nonce in 0..512u32 { + mention = serde_json::json!({ + "id": "OTHER-2", + "status": "blocked", + "cites": DECLARED_KEY, + "nonce": nonce, + }) + .to_string(); + let probe = scratch(&format!("captured-subject-probe-{nonce}")); + let digest = batten::capture::store_in( + &probe, + batten::capture::Stream::Response, + mention.as_bytes(), + ) + .expect("store the probe") + .digest; + if digest < subject_digest { + sorts_first = true; + break; + } + } + assert!( + sorts_first, + "the case needs a citing document that sorts BEFORE the subject, or it \ + cannot tell containment from a subject match" + ); + store_call(&store, "list_issues", &mention); + + let outcome = check(&dir, &home); + let (answer, cause) = (stdout(&outcome), stderr(&outcome)); + assert!( + answer.contains("probe-subject"), + "the reduction must answer from the record the key is the subject of\n{answer}{cause}" + ); + assert!( + !answer.contains("probe-mention"), + "a document that merely cites the key must not answer for it — this is \ + the containment defect (CLOUD-1387)\n{answer}{cause}" + ); +} diff --git a/crates/batten/tests/it/gh_guard.rs b/crates/batten/tests/it/gh_guard.rs index 737a0ddf5..8035ba731 100644 --- a/crates/batten/tests/it/gh_guard.rs +++ b/crates/batten/tests/it/gh_guard.rs @@ -210,11 +210,23 @@ fn gh_pr_ready_is_not_a_lifecycle_refusal() { assert_no_gh_lifecycle_refusal("gh pr ready 63"); } +#[test] +fn gh_pr_create_is_not_a_lifecycle_refusal() { + // THE SAME SHAPE AS `gh pr ready` ABOVE, AND IT SAT ON `allowed` UNTIL + // CLOUD-1384. `pr-names-an-issue` is a `requires_key` row whose evidence is + // the command, the BRANCH NAME, and the subjects on `origin/main..HEAD` — so + // a full allow here asks about the developer's branch rather than about any + // `gh` lifecycle row. Measured: exit 2 from a landed branch carrying no key, + // exit 0 from a keyed one, same tree and same binary. That makes `main` + // itself red, and it passed in CI only because a PR branch satisfies the + // other row by construction. + assert_no_gh_lifecycle_refusal("gh pr create --draft --fill"); +} + #[test] fn the_read_shaped_gh_calls_are_allowed() { allowed("gh pr view 63 --json state"); allowed("gh pr list --state open"); - allowed("gh pr create --draft --fill"); allowed("gh api repos/o/r/commits/abc/check-runs"); allowed("gh run view 12345 --log"); allowed("gh run rerun 12345"); diff --git a/crates/batten/tests/it/stop_posture.rs b/crates/batten/tests/it/stop_posture.rs index 527c883b7..e547229b5 100644 --- a/crates/batten/tests/it/stop_posture.rs +++ b/crates/batten/tests/it/stop_posture.rs @@ -187,9 +187,31 @@ fn stop_payload(message: &str, active: bool) -> String { fn hook(dir: &Path, payload: &str) -> Output { let mut command = batten(); + // CONTAINED, on `hook_in`'s reason and after this suite paid for not being + // (CLOUD-1387). A `repo()` fixture carries no `.git`, so without a ceiling + // git's discovery walks UP — and `CARGO_TARGET_TMPDIR` sits under the real + // checkout, so the walk lands in it. `completion.unlanded` then read the + // developer's own unlanded commits and pre-empted the advisory the case was + // asserting, which made `a_stranded_finding_is_pointed_at_and_the_turn_still_ends` + // pass or fail on whether the person running it had pushed. + // + // The state home goes with it for the same reason `hook_in` states: a real + // session's recorded findings must not decide a fixture's verdict. Both are + // narrowings, so a case that passed under the ambient version passes here + // for a reason it now actually establishes. + common::state_home( + &mut command, + &scratch(&format!( + "{}-home", + dir.file_name() + .and_then(std::ffi::OsStr::to_str) + .unwrap_or("stop-posture") + )), + ); command .current_dir(dir) .args(["hook", "--harness", "claude-code"]) + .env("GIT_CEILING_DIRECTORIES", env!("CARGO_TARGET_TMPDIR")) .env_remove("BATTEN_HOOK_BYPASS") .stdin(Stdio::piped()) .stdout(Stdio::piped()) diff --git a/schema/batten.local.schema.json b/schema/batten.local.schema.json index 4be2f516f..fb15ae902 100644 --- a/schema/batten.local.schema.json +++ b/schema/batten.local.schema.json @@ -119,9 +119,16 @@ "type": "string" }, "key": { - "description": "The token that selects which captured response answers.\n\nAn opaque string the consumer supplies. The engine knows nothing about\nwhat it names — it matches captures containing it and reduces the first in\nhandle order, which is where non-negotiable rule 1 is paid: a tracker's\nkey vocabulary is the consumer's fact and never this crate's.", + "description": "The token that selects which captured response answers.\n\nAn opaque string the consumer supplies. The engine knows nothing about\nwhat it names, which is where non-negotiable rule 1 is paid: a tracker's\nkey vocabulary is the consumer's fact and never this crate's.\n\nHow it SELECTS depends on [`Self::key_at`], and the two are different\nquestions — see that field.", "type": "string" }, + "key_at": { + "description": "Where the key sits in the response, in [`Node::at`]'s spelling.\n\n# This is the difference between \"the record about the key\" and \"a record mentioning it\"\n\nWithout it, selection is byte CONTAINMENT over the response and the first\nmatch in handle order answers — so any stored document that merely cites\nthe key competes, and which one wins is decided by a digest. Measured\n2026-09-03 over this repository's own store (CLOUD-1387): 14 captures\ncontained `CLOUD-1188`, the one read carried no `project` node and\nanswered `false`, and the `get_issue` for that row — carrying\n`project: \"Batten\"` — sorted later and was never consulted. A filed row\nwas reported unfiled, from a payload that was never about it.\n\nWith it, the row is resolved through [`crate::capture::find`], which\nselects a response whose scalar AT THIS PATH equals the key and takes the\nmost recent in the log's append order. That is the same selector\n`capture find --key-at` already exposes, so there is one authority on what\n\"the capture for this key\" means rather than two.\n\n**Optional, and absent keeps the old meaning rather than changing a\nverdict silently.** A row that declares no path is still resolved by\ncontainment: this field can only ever narrow what answers, so adding it is\nraise-only in house-style §8's sense, and a consumer's landed rows do not\nmove underneath them.\n\n**The PATH is the consumer's, which is what keeps rule 1 intact.** The\nengine never names `id`, or any other tracker field — it reads the path\nthe row supplies, exactly as `node` below is read.", + "type": [ + "string", + "null" + ] + }, "node": { "description": "The node path inside the selected response, in [`Node::at`]'s spelling.", "type": "string" diff --git a/schema/batten.schema.json b/schema/batten.schema.json index 5527ebd9c..ce1cb984b 100644 --- a/schema/batten.schema.json +++ b/schema/batten.schema.json @@ -697,9 +697,16 @@ "type": "string" }, "key": { - "description": "The token that selects which captured response answers.\n\nAn opaque string the consumer supplies. The engine knows nothing about\nwhat it names — it matches captures containing it and reduces the first in\nhandle order, which is where non-negotiable rule 1 is paid: a tracker's\nkey vocabulary is the consumer's fact and never this crate's.", + "description": "The token that selects which captured response answers.\n\nAn opaque string the consumer supplies. The engine knows nothing about\nwhat it names, which is where non-negotiable rule 1 is paid: a tracker's\nkey vocabulary is the consumer's fact and never this crate's.\n\nHow it SELECTS depends on [`Self::key_at`], and the two are different\nquestions — see that field.", "type": "string" }, + "key_at": { + "description": "Where the key sits in the response, in [`Node::at`]'s spelling.\n\n# This is the difference between \"the record about the key\" and \"a record mentioning it\"\n\nWithout it, selection is byte CONTAINMENT over the response and the first\nmatch in handle order answers — so any stored document that merely cites\nthe key competes, and which one wins is decided by a digest. Measured\n2026-09-03 over this repository's own store (CLOUD-1387): 14 captures\ncontained `CLOUD-1188`, the one read carried no `project` node and\nanswered `false`, and the `get_issue` for that row — carrying\n`project: \"Batten\"` — sorted later and was never consulted. A filed row\nwas reported unfiled, from a payload that was never about it.\n\nWith it, the row is resolved through [`crate::capture::find`], which\nselects a response whose scalar AT THIS PATH equals the key and takes the\nmost recent in the log's append order. That is the same selector\n`capture find --key-at` already exposes, so there is one authority on what\n\"the capture for this key\" means rather than two.\n\n**Optional, and absent keeps the old meaning rather than changing a\nverdict silently.** A row that declares no path is still resolved by\ncontainment: this field can only ever narrow what answers, so adding it is\nraise-only in house-style §8's sense, and a consumer's landed rows do not\nmove underneath them.\n\n**The PATH is the consumer's, which is what keeps rule 1 intact.** The\nengine never names `id`, or any other tracker field — it reads the path\nthe row supplies, exactly as `node` below is read.", + "type": [ + "string", + "null" + ] + }, "node": { "description": "The node path inside the selected response, in [`Node::at`]'s spelling.", "type": "string"