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"