From 76939465c44124ff419071795891017ec2c95392 Mon Sep 17 00:00:00 2001 From: OceanLi Date: Sat, 12 Sep 2026 08:40:14 -0400 Subject: [PATCH] fix(runtime): locate the remaining secret-gate refusals Forty-eight call sites already named the record and field a content gate refusal fired on. The rest did not, so a refusal from the knowledge pack's section ingest, the brain pack handlers, the code pack, the direct code ingest path, the stream writer and the archive importer still told the caller only what matched, not where the scan was looking. This converts them to the locating wrappers. Four batch loops gain an index so a refusal from a bulk call reads section[3].content or entity[1].name rather than naming a field with no record. The section index is the position in the discovered list, which is what the caller submitted, not the position among the sections that survived the minimum length filter. Two documentation drifts land with it. The wrappers called the first parameter scope and described it as the verb for a single-record write, while every call site in the tree passes a record noun; the code was right, so the parameter and the prose now both say record. The two outbound delivery paths both reported message.delivery, and the payload has no field by that name: one scans delivered_at and transport_message_id, the other failed_at and last_error, so they now report message.delivered and message.failed. Two sites in the version-control sync path are deliberately unconverted. They already name the record and the field in the error context they build, so converting them would say it twice. --- crates/khive-pack-brain/src/handlers.rs | 14 +++++----- crates/khive-pack-code/src/source_ingest.rs | 6 ++-- .../src/knowledge/crud.rs | 26 ++++++++--------- .../src/knowledge/sections.rs | 28 ++++++++++--------- crates/khive-runtime/src/curation.rs | 4 +-- crates/khive-runtime/src/note_write.rs | 2 +- crates/khive-runtime/src/portability.rs | 5 ++-- crates/khive-runtime/src/secret_gate.rs | 23 +++++++-------- crates/khive-runtime/src/streams.rs | 14 ++++++++-- crates/kkernel/src/code_ingest.rs | 25 +++++++++++------ 10 files changed, 83 insertions(+), 64 deletions(-) diff --git a/crates/khive-pack-brain/src/handlers.rs b/crates/khive-pack-brain/src/handlers.rs index 1661008c8..076e9bd1c 100644 --- a/crates/khive-pack-brain/src/handlers.rs +++ b/crates/khive-pack-brain/src/handlers.rs @@ -2383,7 +2383,7 @@ impl BrainPack { let p: MarkTurnParams = serde_json::from_value(params) .map_err(|e| RuntimeError::InvalidInput(e.to_string()))?; if let Some(label) = p.label.as_deref() { - khive_runtime::secret_gate::check(label)?; + khive_runtime::secret_gate::check_at(label, "turn", "label")?; } let phase = p.label.unwrap_or_else(|| "actor_turn".to_string()); let actor = format!("{}:{}", token.actor().kind, token.actor().id); @@ -2459,13 +2459,13 @@ impl BrainPack { // Secret gate: scan arbitrary text fields before writing. // Wildcard sentinel `*` is safe; real values are scanned. if actor != "*" { - khive_runtime::secret_gate::check(&actor)?; + khive_runtime::secret_gate::check_at(&actor, "binding", "actor")?; } if namespace != "*" { - khive_runtime::secret_gate::check(&namespace)?; + khive_runtime::secret_gate::check_at(&namespace, "binding", "namespace")?; } if consumer_kind != "*" { - khive_runtime::secret_gate::check(&consumer_kind)?; + khive_runtime::secret_gate::check_at(&consumer_kind, "binding", "consumer_kind")?; } if consumer_kind != "*" { @@ -2730,10 +2730,10 @@ impl BrainPack { // Secret gate: scan caller-supplied text before any write. // `p_name` is already constrained to [a-zA-Z0-9-]+ and cannot carry a secret. - khive_runtime::secret_gate::check(&description)?; - khive_runtime::secret_gate::check(&consumer_kind)?; + khive_runtime::secret_gate::check_at(&description, "profile", "description")?; + khive_runtime::secret_gate::check_at(&consumer_kind, "profile", "consumer_kind")?; if let Some(ref seed) = p.seed_priors { - khive_runtime::secret_gate::check_json(seed)?; + khive_runtime::secret_gate::check_json_at(seed, "profile", "seed_priors")?; } let seed_priors = p.seed_priors; diff --git a/crates/khive-pack-code/src/source_ingest.rs b/crates/khive-pack-code/src/source_ingest.rs index 2dbb91fbc..e07a32f2e 100644 --- a/crates/khive-pack-code/src/source_ingest.rs +++ b/crates/khive-pack-code/src/source_ingest.rs @@ -632,12 +632,12 @@ async fn get_entity_opt( /// never set `description`, so this is additive and does not change their /// gate coverage. fn gate_check(entity: &Entity) -> Result<(), RuntimeError> { - secret_gate::check(&entity.name)?; + secret_gate::check_at(&entity.name, "entity", "name")?; if let Some(description) = &entity.description { - secret_gate::check(description)?; + secret_gate::check_at(description, "entity", "description")?; } if let Some(properties) = &entity.properties { - secret_gate::check_json(properties)?; + secret_gate::check_json_at(properties, "entity", "properties")?; } Ok(()) } diff --git a/crates/khive-pack-knowledge/src/knowledge/crud.rs b/crates/khive-pack-knowledge/src/knowledge/crud.rs index 76f3f75bf..7710320fc 100644 --- a/crates/khive-pack-knowledge/src/knowledge/crud.rs +++ b/crates/khive-pack-knowledge/src/knowledge/crud.rs @@ -272,23 +272,23 @@ impl KnowledgeHandlers { // atom that produced it by POSITION, never by slug: this loop returns ONE // error for the whole batch, the slug is itself a scanned field, and two // atoms may share a slug within one payload (#2605). - use khive_runtime::secret_gate::{self, locate}; + use khive_runtime::secret_gate; let record = format!("atoms[{index}]"); - locate(secret_gate::check(&slug), &record, "slug")?; - locate(secret_gate::check(&atom_in.name), &record, "name")?; - locate(secret_gate::check(&content), &record, "content")?; + secret_gate::check_at(&slug, &record, "slug")?; + secret_gate::check_at(&atom_in.name, &record, "name")?; + secret_gate::check_at(&content, &record, "content")?; if let Some(ref tags_vec) = atom_in.tags { - locate(secret_gate::check_tags(tags_vec), &record, "tags")?; + secret_gate::check_tags_at(tags_vec, &record, "tags")?; } if let Some(ref props) = atom_in.properties { - locate(secret_gate::check_json(props), &record, "properties")?; + secret_gate::check_json_at(props, &record, "properties")?; } secret_gate::reject_reserved_secret_gate_property(atom_in.properties.as_ref())?; if let Some(Some(uri)) = &atom_in.source_uri { - locate(secret_gate::check(uri), &record, "source_uri")?; + secret_gate::check_at(uri, &record, "source_uri")?; } if let Some(Some(st)) = &atom_in.source_type { - locate(secret_gate::check(st), &record, "source_type")?; + secret_gate::check_at(st, &record, "source_type")?; } } @@ -487,8 +487,8 @@ impl KnowledgeHandlers { } // Secret gate: scan slug and name first (before content-length validation) // so security violations short-circuit before business logic errors. - khive_runtime::secret_gate::check(&slug)?; - khive_runtime::secret_gate::check(&name)?; + khive_runtime::secret_gate::check_at(&slug, "domain", "slug")?; + khive_runtime::secret_gate::check_at(&name, "domain", "name")?; // Domain mirror atoms are written to knowledge_atoms with the description // as content. Enforce the same 20-word minimum that normal atoms must satisfy // so the FTS and embedding surfaces receive adequate content. @@ -497,12 +497,12 @@ impl KnowledgeHandlers { RuntimeError::InvalidInput(format!("domain {slug:?}: description {e}")) })?; // Secret gate: scan remaining caller-supplied text. - khive_runtime::secret_gate::check(mirror_content)?; + khive_runtime::secret_gate::check_at(mirror_content, "domain", "description")?; if let Some(ref tags_vec) = domain_in.tags { - khive_runtime::secret_gate::check_tags(tags_vec)?; + khive_runtime::secret_gate::check_tags_at(tags_vec, "domain", "tags")?; } if let Some(ref members_vec) = domain_in.members { - khive_runtime::secret_gate::check_tags(members_vec)?; + khive_runtime::secret_gate::check_tags_at(members_vec, "domain", "members")?; } let mut tags: Vec = domain_in.tags.clone().unwrap_or_default(); diff --git a/crates/khive-pack-knowledge/src/knowledge/sections.rs b/crates/khive-pack-knowledge/src/knowledge/sections.rs index fe455f315..8c589120a 100644 --- a/crates/khive-pack-knowledge/src/knowledge/sections.rs +++ b/crates/khive-pack-knowledge/src/knowledge/sections.rs @@ -677,14 +677,15 @@ fn prepare_import_file( let (sections, sections_skipped) = if chunk_strategy == "section" { let mut prepared = Vec::new(); let mut skipped = 0usize; - for (section_type, heading, content) in parsed_sections { + for (index, (section_type, heading, content)) in parsed_sections.into_iter().enumerate() { + let record = format!("section[{index}]"); if content.len() < super::util::MIN_SECTION_CONTENT_LEN { skipped = skipped.saturating_add(1); continue; } validate_section_content(&content)?; - khive_runtime::secret_gate::check(&heading)?; - khive_runtime::secret_gate::check(&content)?; + khive_runtime::secret_gate::check_at(&heading, &record, "heading")?; + khive_runtime::secret_gate::check_at(&content, &record, "content")?; prepared.push(PreparedSection { section_type, heading, @@ -696,15 +697,15 @@ fn prepare_import_file( (Vec::new(), 0) }; - khive_runtime::secret_gate::check(&slug)?; - khive_runtime::secret_gate::check(&name)?; - khive_runtime::secret_gate::check_tags(&frontmatter.tags)?; - khive_runtime::secret_gate::check(&atom_content)?; + khive_runtime::secret_gate::check_at(&slug, "file", "slug")?; + khive_runtime::secret_gate::check_at(&name, "file", "name")?; + khive_runtime::secret_gate::check_tags_at(&frontmatter.tags, "file", "tags")?; + khive_runtime::secret_gate::check_at(&atom_content, "file", "content")?; let properties_value = Value::Object(properties.clone()); - khive_runtime::secret_gate::check_json(&properties_value)?; + khive_runtime::secret_gate::check_json_at(&properties_value, "file", "properties")?; khive_runtime::secret_gate::reject_reserved_secret_gate_property(Some(&properties_value))?; - khive_runtime::secret_gate::check(&source_uri)?; - khive_runtime::secret_gate::check(source_type)?; + khive_runtime::secret_gate::check_at(&source_uri, "file", "source_uri")?; + khive_runtime::secret_gate::check_at(source_type, "file", "source_type")?; Ok(PreparedImportFile { slug, @@ -924,13 +925,14 @@ impl KnowledgeHandlers { let mut upserted = 0usize; let mut section_results: Vec = Vec::with_capacity(p.sections.len()); - for su in &p.sections { + for (index, su) in p.sections.iter().enumerate() { + let record = format!("section[{index}]"); let stype = parse_section_type(&su.section_type)?; validate_section_content(&su.content)?; // Secret gate: scan section content and heading before any write. - khive_runtime::secret_gate::check(&su.content)?; + khive_runtime::secret_gate::check_at(&su.content, &record, "content")?; if let Some(ref h) = su.heading { - khive_runtime::secret_gate::check(h)?; + khive_runtime::secret_gate::check_at(h, &record, "heading")?; } let heading = su.heading.as_deref().unwrap_or(stype.as_str()).to_string(); let tokens = count_tokens(&su.content); diff --git a/crates/khive-runtime/src/curation.rs b/crates/khive-runtime/src/curation.rs index 4cf9a2311..89b775e65 100644 --- a/crates/khive-runtime/src/curation.rs +++ b/crates/khive-runtime/src/curation.rs @@ -2184,7 +2184,7 @@ impl KhiveRuntime { "transport_message_id": &transport_message_id, }), "message", - "delivery", + "delivered", )?; let snapshot = self.outbound_message(token, id).await?; if Self::outbound_delivery_is_terminal( @@ -2233,7 +2233,7 @@ impl KhiveRuntime { "last_error": &last_error, }), "message", - "delivery", + "failed", )?; let snapshot = self.outbound_message(token, id).await?; if Self::outbound_delivery_is_terminal( diff --git a/crates/khive-runtime/src/note_write.rs b/crates/khive-runtime/src/note_write.rs index 5b321d22b..6a5811aea 100644 --- a/crates/khive-runtime/src/note_write.rs +++ b/crates/khive-runtime/src/note_write.rs @@ -648,7 +648,7 @@ impl KhiveRuntime { "embedding_content must be a non-empty proper prefix of content".into(), )); } - crate::secret_gate::check(prefix)?; + crate::secret_gate::check_at(prefix, "note", "embedding_content")?; } let mut candidate = khive_storage::note::Note::new(token.namespace().as_str(), kind, content); diff --git a/crates/khive-runtime/src/portability.rs b/crates/khive-runtime/src/portability.rs index 4ca6201a4..e2c07646e 100644 --- a/crates/khive-runtime/src/portability.rs +++ b/crates/khive-runtime/src/portability.rs @@ -259,7 +259,8 @@ impl KhiveRuntime { ))); } } - for edge in &archive.edges { + for (index, edge) in archive.edges.iter().enumerate() { + let record = format!("edge[{index}]"); crate::operations::validate_edge_weight(edge.weight)?; // Edge properties are caller-controlled input: the runtime-owned // `khive:secret_gate` key is reservation-only on import, and edge @@ -267,7 +268,7 @@ impl KhiveRuntime { // class, so credential-shaped values are rejected here as well. crate::secret_gate::reject_reserved_secret_gate_property(edge.properties.as_ref())?; if let Some(p) = edge.properties.as_ref() { - crate::secret_gate::check_json(p)?; + crate::secret_gate::check_json_at(p, &record, "properties")?; } } diff --git a/crates/khive-runtime/src/secret_gate.rs b/crates/khive-runtime/src/secret_gate.rs index ea8f10203..1bd31d132 100644 --- a/crates/khive-runtime/src/secret_gate.rs +++ b/crates/khive-runtime/src/secret_gate.rs @@ -150,9 +150,10 @@ pub fn check_tags(tags: &[String]) -> RuntimeResult<()> { /// rejected with it (khive #2605). Pass through anything that is not a gate /// refusal unchanged — this adds identity, it does not reclassify. /// -/// `scope` is a record label for a batch (`notes[2]`) and the verb for a -/// single-record write (`comm.send`). Both answer the same question, which is -/// where in the submitted payload the writer should look. +/// `record` names the record the field belongs to, as the caller submitted it: +/// `entity`, `note`, `task`, `proposal`, `message`, indexed when it came from a +/// batch (`note[2]`). It answers where in the submitted payload the writer +/// should look, which is the question a refused writer actually asks. pub fn locate(result: RuntimeResult, record: &str, field: &str) -> RuntimeResult { result.map_err(|error| match error { RuntimeError::SecretDetected(matched) => RuntimeError::SecretDetected(SecretMatch { @@ -163,21 +164,21 @@ pub fn locate(result: RuntimeResult, record: &str, field: &str) -> Runtime }) } -/// `check` that names where it looked. `scope` is the verb for a single-record -/// write, a record label inside a batch. -pub fn check_at(content: &str, scope: &str, field: &str) -> RuntimeResult<()> { - locate(check(content), scope, field) +/// `check` that names where it looked. `record` is the record noun the caller +/// submitted, indexed inside a batch; `field` is the field of it that was scanned. +pub fn check_at(content: &str, record: &str, field: &str) -> RuntimeResult<()> { + locate(check(content), record, field) } /// `check_json` that names where it looked. The location is the field holding /// the JSON, not the path of the string leaf that matched inside it. -pub fn check_json_at(value: &serde_json::Value, scope: &str, field: &str) -> RuntimeResult<()> { - locate(check_json(value), scope, field) +pub fn check_json_at(value: &serde_json::Value, record: &str, field: &str) -> RuntimeResult<()> { + locate(check_json(value), record, field) } /// `check_tags` that names where it looked. -pub fn check_tags_at(tags: &[String], scope: &str, field: &str) -> RuntimeResult<()> { - locate(check_tags(tags), scope, field) +pub fn check_tags_at(tags: &[String], record: &str, field: &str) -> RuntimeResult<()> { + locate(check_tags(tags), record, field) } // ─── Reserved property key (ADR-115 Amendment 1) ──────────────────────────── diff --git a/crates/khive-runtime/src/streams.rs b/crates/khive-runtime/src/streams.rs index b5664988b..494a43b16 100644 --- a/crates/khive-runtime/src/streams.rs +++ b/crates/khive-runtime/src/streams.rs @@ -1050,9 +1050,11 @@ impl KhiveRuntime { self.validate_note_kind(&fence.kind)?; } } - crate::secret_gate::check( + crate::secret_gate::check_at( &serde_json::to_string(&spec.record) .map_err(|error| RuntimeError::InvalidInput(error.to_string()))?, + &format!("member[{index}]"), + "record", )?; } StreamBatchMember::Write(spec) => { @@ -1075,12 +1077,18 @@ impl KhiveRuntime { spec.key, spec.kind, ))); } - crate::secret_gate::check( + crate::secret_gate::check_at( &serde_json::to_string(&spec.doc) .map_err(|error| RuntimeError::InvalidInput(error.to_string()))?, + &format!("member[{index}]"), + "doc", )?; if let Some(tags) = &spec.tags { - crate::secret_gate::check_json(&json!({"tags": tags}))?; + crate::secret_gate::check_json_at( + &json!({"tags": tags}), + &format!("member[{index}]"), + "tags", + )?; } } StreamBatchMember::Refused(_) => {} diff --git a/crates/kkernel/src/code_ingest.rs b/crates/kkernel/src/code_ingest.rs index 61401259e..a03d81c4c 100644 --- a/crates/kkernel/src/code_ingest.rs +++ b/crates/kkernel/src/code_ingest.rs @@ -552,25 +552,32 @@ async fn settle_writer_drain( /// evidence is rejected here exactly as it would be on the shared `create` /// verb path, rather than persisting verbatim. fn preflight_secret_gate(batch: &CodeIngestBatch) -> Result<()> { - for entity in &batch.entities { - secret_gate::check(&entity.name).map_err(|e| anyhow::anyhow!("{e}"))?; + for (index, entity) in batch.entities.iter().enumerate() { + let record = format!("entity[{index}]"); + secret_gate::check_at(&entity.name, &record, "name").map_err(|e| anyhow::anyhow!("{e}"))?; if let Some(description) = &entity.description { - secret_gate::check(description).map_err(|e| anyhow::anyhow!("{e}"))?; + secret_gate::check_at(description, &record, "description") + .map_err(|e| anyhow::anyhow!("{e}"))?; } if let Some(properties) = &entity.properties { - secret_gate::check_json(properties).map_err(|e| anyhow::anyhow!("{e}"))?; + secret_gate::check_json_at(properties, &record, "properties") + .map_err(|e| anyhow::anyhow!("{e}"))?; } secret_gate::reject_reserved_secret_gate_property(entity.properties.as_ref()) .map_err(|e| anyhow::anyhow!("{e}"))?; - secret_gate::check_tags(&entity.tags).map_err(|e| anyhow::anyhow!("{e}"))?; + secret_gate::check_tags_at(&entity.tags, &record, "tags") + .map_err(|e| anyhow::anyhow!("{e}"))?; } - for note in &batch.notes { - secret_gate::check(¬e.content).map_err(|e| anyhow::anyhow!("{e}"))?; + for (index, note) in batch.notes.iter().enumerate() { + let record = format!("note[{index}]"); + secret_gate::check_at(¬e.content, &record, "content") + .map_err(|e| anyhow::anyhow!("{e}"))?; if let Some(name) = ¬e.name { - secret_gate::check(name).map_err(|e| anyhow::anyhow!("{e}"))?; + secret_gate::check_at(name, &record, "name").map_err(|e| anyhow::anyhow!("{e}"))?; } if let Some(properties) = ¬e.properties { - secret_gate::check_json(properties).map_err(|e| anyhow::anyhow!("{e}"))?; + secret_gate::check_json_at(properties, &record, "properties") + .map_err(|e| anyhow::anyhow!("{e}"))?; } secret_gate::reject_reserved_secret_gate_property(note.properties.as_ref()) .map_err(|e| anyhow::anyhow!("{e}"))?;