From 2513ef45ee5ca30c043be1761bf1e0124349aa56 Mon Sep 17 00:00:00 2001 From: Leo Date: Mon, 13 Jul 2026 12:32:17 -0400 Subject: [PATCH 1/5] fix(brain): thread caller actor identity into FeedbackExplicit events FeedbackExplicit hardcoded actor to the string brain, making per-seat feedback attribution impossible. Thread the resolved per-request caller identity into the event payload with an explicit fallback when none resolves. Fixes #808 Co-Authored-By: Claude Fable 5 --- crates/khive-pack-brain/src/handlers.rs | 13 ++- crates/khive-pack-brain/src/tests.rs | 100 ++++++++++++++++++++++++ 2 files changed, 111 insertions(+), 2 deletions(-) diff --git a/crates/khive-pack-brain/src/handlers.rs b/crates/khive-pack-brain/src/handlers.rs index b160401b7..190e90ea5 100644 --- a/crates/khive-pack-brain/src/handlers.rs +++ b/crates/khive-pack-brain/src/handlers.rs @@ -1437,6 +1437,14 @@ impl BrainPack { }; let namespace = token.namespace().as_str().to_string(); + // ADR-096 per-request identity: stamp the resolved caller actor, + // not a hardcoded pack name — matches the `kind:id` convention + // the generic Audit event already uses (khive-runtime pack.rs + // `build_audit_storage_event`). `ActorRef::anonymous()` resolves + // to the explicit `"anonymous:local"` string rather than + // silently mislabeling the event, so unresolved-actor calls are + // still distinguishable from real per-seat attribution. + let actor_label = format!("{}:{}", token.actor().kind, token.actor().id); // `apply_fold_gate_and_append_event`'s `build_event` closure is // now required to be `'static` (ADR-067 Component A, Fork C // slice 2 — it is boxed into an `AtomicUnitOp` and may run @@ -1445,6 +1453,7 @@ impl BrainPack { // separate `namespace_for_event` clone avoids conflicting with // the `&namespace` borrow passed as this call's own argument. let namespace_for_event = namespace.clone(); + let actor_label_for_event = actor_label.clone(); let base_data_for_event = base_data.clone(); let outcome = crate::fold_gate::apply_fold_gate_and_append_event( sql.as_ref(), @@ -1472,7 +1481,7 @@ impl BrainPack { "brain.feedback", khive_types::EventKind::FeedbackExplicit, target_substrate, - "brain", + actor_label_for_event, ) .with_target(target) .with_payload(data) @@ -1514,7 +1523,7 @@ impl BrainPack { "brain.feedback", khive_types::EventKind::FeedbackExplicit, target_substrate, - "brain", + format!("{}:{}", token.actor().kind, token.actor().id), ) .with_target(target) .with_payload(base_data) diff --git a/crates/khive-pack-brain/src/tests.rs b/crates/khive-pack-brain/src/tests.rs index 67807ad01..abd0fa33f 100644 --- a/crates/khive-pack-brain/src/tests.rs +++ b/crates/khive-pack-brain/src/tests.rs @@ -6613,3 +6613,103 @@ mod event_counts_tests { ); } } + +/// #808: `brain.feedback`'s `FeedbackExplicit` event must carry the resolved +/// caller identity (ADR-096 per-request identity), not the hardcoded literal +/// `"brain"` it used to stamp regardless of who called it. +mod feedback_actor_tests { + use super::*; + + fn make_pack_with_actor(actor_id: Option<&str>) -> (BrainPack, KhiveRuntime) { + let rt = KhiveRuntime::new(khive_runtime::RuntimeConfig { + db_path: None, + packs: vec!["kg".to_string()], + brain_profile: None, + actor_id: actor_id.map(|s| s.to_string()), + ..khive_runtime::RuntimeConfig::no_embeddings() + }) + .expect("in-memory runtime"); + let pack = BrainPack::new(rt.clone()); + (pack, rt) + } + + /// Dispatch `brain.feedback` with `signal` and return the emitted + /// `FeedbackExplicit` event's `actor` field, read back from the event store. + async fn feedback_event_actor( + pack: &BrainPack, + rt: &KhiveRuntime, + token: &NamespaceToken, + signal: &str, + ) -> Option { + let registry = empty_registry(); + let target = create_test_entity(rt, token).await; + let result = pack + .dispatch( + "brain.feedback", + json!({"target_id": target, "signal": signal}), + ®istry, + token, + ) + .await + .expect("brain.feedback must succeed"); + let event_id = uuid::Uuid::parse_str( + result["event_id"] + .as_str() + .expect("response must carry event_id"), + ) + .expect("event_id must be a uuid"); + let event = rt + .events(token) + .expect("event store") + .get_event(event_id) + .await + .expect("get_event must not error") + .expect("emitted event must be readable back"); + event.actor + } + + #[tokio::test] + async fn explicit_signal_actor_reflects_resolved_identity_not_hardcoded_brain() { + // "useful" is not in `FeedbackEventKind::from_signal_str`, so this + // exercises the ungated explicit/correction append path. + let (pack, rt) = make_pack_with_actor(Some("seat:tester")); + let token = rt.authorize(Namespace::local()).unwrap(); + let actor = feedback_event_actor(&pack, &rt, &token, "useful").await; + assert_eq!( + actor.as_deref(), + Some("actor:seat:tester"), + "FeedbackExplicit actor must reflect the resolved caller identity, \ + not a hardcoded \"brain\"" + ); + } + + #[tokio::test] + async fn implicit_signal_actor_reflects_resolved_identity_not_hardcoded_brain() { + // "implicit_positive" routes through the gated fold-gate append path + // (`apply_fold_gate_and_append_event`'s `'static` closure), a second, + // independent call site that hardcoded the same literal. + let (pack, rt) = make_pack_with_actor(Some("seat:tester")); + let token = rt.authorize(Namespace::local()).unwrap(); + let actor = feedback_event_actor(&pack, &rt, &token, "implicit_positive").await; + assert_eq!( + actor.as_deref(), + Some("actor:seat:tester"), + "gated-implicit FeedbackExplicit actor must reflect the resolved caller identity" + ); + } + + #[tokio::test] + async fn unresolved_actor_falls_back_explicitly_never_to_hardcoded_brain() { + // No `actor_id` configured -> `resolve_actor` yields `ActorRef::anonymous()`. + // The fallback must be the explicit "anonymous:local" label, never a + // silent, plausible-looking "brain" stand-in. + let (pack, rt) = make_pack_with_actor(None); + let token = rt.authorize(Namespace::local()).unwrap(); + let actor = feedback_event_actor(&pack, &rt, &token, "useful").await; + assert_eq!( + actor.as_deref(), + Some("anonymous:local"), + "unresolved actor must fall back to an explicit label, never the literal \"brain\"" + ); + } +} From 8239126f323452e1f0dbafae9598acf9d2e4e9d3 Mon Sep 17 00:00:00 2001 From: Leo Date: Mon, 13 Jul 2026 15:56:22 -0400 Subject: [PATCH 2/5] fix(brain): compile fix for actor read-back helper + neutral comment wording --- crates/khive-pack-brain/src/handlers.rs | 2 +- crates/khive-pack-brain/src/tests.rs | 11 ++++------- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/crates/khive-pack-brain/src/handlers.rs b/crates/khive-pack-brain/src/handlers.rs index 190e90ea5..bdd21f98f 100644 --- a/crates/khive-pack-brain/src/handlers.rs +++ b/crates/khive-pack-brain/src/handlers.rs @@ -1443,7 +1443,7 @@ impl BrainPack { // `build_audit_storage_event`). `ActorRef::anonymous()` resolves // to the explicit `"anonymous:local"` string rather than // silently mislabeling the event, so unresolved-actor calls are - // still distinguishable from real per-seat attribution. + // still distinguishable from configured caller attribution. let actor_label = format!("{}:{}", token.actor().kind, token.actor().id); // `apply_fold_gate_and_append_event`'s `build_event` closure is // now required to be `'static` (ADR-067 Component A, Fork C diff --git a/crates/khive-pack-brain/src/tests.rs b/crates/khive-pack-brain/src/tests.rs index abd0fa33f..9465eeca5 100644 --- a/crates/khive-pack-brain/src/tests.rs +++ b/crates/khive-pack-brain/src/tests.rs @@ -6640,7 +6640,7 @@ mod feedback_actor_tests { rt: &KhiveRuntime, token: &NamespaceToken, signal: &str, - ) -> Option { + ) -> String { let registry = empty_registry(); let target = create_test_entity(rt, token).await; let result = pack @@ -6676,8 +6676,7 @@ mod feedback_actor_tests { let token = rt.authorize(Namespace::local()).unwrap(); let actor = feedback_event_actor(&pack, &rt, &token, "useful").await; assert_eq!( - actor.as_deref(), - Some("actor:seat:tester"), + actor, "actor:seat:tester", "FeedbackExplicit actor must reflect the resolved caller identity, \ not a hardcoded \"brain\"" ); @@ -6692,8 +6691,7 @@ mod feedback_actor_tests { let token = rt.authorize(Namespace::local()).unwrap(); let actor = feedback_event_actor(&pack, &rt, &token, "implicit_positive").await; assert_eq!( - actor.as_deref(), - Some("actor:seat:tester"), + actor, "actor:seat:tester", "gated-implicit FeedbackExplicit actor must reflect the resolved caller identity" ); } @@ -6707,8 +6705,7 @@ mod feedback_actor_tests { let token = rt.authorize(Namespace::local()).unwrap(); let actor = feedback_event_actor(&pack, &rt, &token, "useful").await; assert_eq!( - actor.as_deref(), - Some("anonymous:local"), + actor, "anonymous:local", "unresolved actor must fall back to an explicit label, never the literal \"brain\"" ); } From ddfdbb1f3a48c2aae4305b82e67f8411c46191ab Mon Sep 17 00:00:00 2001 From: Leo Date: Mon, 13 Jul 2026 17:49:45 -0400 Subject: [PATCH 3/5] test(mcp): keep schedule routing regression hermetic --- crates/khive-mcp/src/serve.rs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/crates/khive-mcp/src/serve.rs b/crates/khive-mcp/src/serve.rs index 6116fac2f..a9fd7a163 100644 --- a/crates/khive-mcp/src/serve.rs +++ b/crates/khive-mcp/src/serve.rs @@ -5048,11 +5048,21 @@ backend = "kg-backend" ); use clap::Parser; - let args = Args::parse_from(["mcp", "--config", config_path.to_str().expect("utf8 path")]); + let args = Args::parse_from([ + "mcp", + "--config", + config_path.to_str().expect("utf8 path"), + "--no-embed", + ]); let (server, schedule_rt) = build_server(&args).expect("build_server must succeed"); // No `[packs.schedule]` entry above, so it defaults to "main". let rt = schedule_rt.expect("schedule pack is loaded by default"); + assert!( + rt.config().embedding_model.is_none() + && rt.config().additional_embedding_models.is_empty(), + "the backend-routing fixture must remain independent of external embedding models" + ); let marker = "adr106-multi-backend-dispatch-marker"; let action_dsl = format!("create(kind=\"observation\", content=\"{marker}\")"); From 7871897f1dde987658144b9a30e6ed37b94dfe46 Mon Sep 17 00:00:00 2001 From: Leo Date: Mon, 13 Jul 2026 17:02:23 -0400 Subject: [PATCH 4/5] test(khive-db): serialize writer-task tests on tx_registry to fix flaky tx-age sweep run_writer_task registers a writer_task_tx handle in the process-wide tx_registry singleton; the checkpoint tx_age_sweep_* tests read that singleton under #[serial(tx_registry)]. The writer_task.rs tests that spawn a writer task were missing from the group, leaking a longer-lived writer_task_tx into the sweep's oldest() read and intermittently failing the assertion on main CI. Join them to the serial group. No prod change. --- crates/khive-db/src/writer_task.rs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/crates/khive-db/src/writer_task.rs b/crates/khive-db/src/writer_task.rs index c13590065..17738a9a5 100644 --- a/crates/khive-db/src/writer_task.rs +++ b/crates/khive-db/src/writer_task.rs @@ -420,6 +420,7 @@ async fn run_writer_task( mod tests { use super::*; use crate::pool::PoolConfig; + use serial_test::serial; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use std::time::Duration; @@ -432,7 +433,14 @@ mod tests { ConnectionPool::new(cfg).expect("pool open") } + // `#[serial(tx_registry)]`: `run_writer_task` registers a `writer_task_tx` + // handle in the process-wide `tx_registry` singleton for the life of each + // `BEGIN IMMEDIATE`. Tests that observe the registry (the checkpoint + // `tx_age_sweep_*` group) read `tx_registry::oldest()`; an un-serialized + // spawning test here would leak a longer-lived `writer_task_tx` into that + // read and make the sweep name the wrong transaction. Share the key. #[tokio::test] + #[serial(tx_registry)] async fn begin_immediate_failure_replies_error_without_running_op() { // Real lock contention, not a simulation: hold the database-level // write lock from the pool's own writer connection (the unmigrated @@ -504,7 +512,11 @@ mod tests { ); } + // `#[serial(tx_registry)]`: shares the key with the checkpoint + // `tx_age_sweep_*` tests — see the note on + // `begin_immediate_failure_replies_error_without_running_op`. #[tokio::test] + #[serial(tx_registry)] async fn writer_task_executes_op_and_commits() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("writer_task_commit.db"); @@ -627,7 +639,13 @@ mod tests { first.abort(); } + // `#[serial(tx_registry)]`: this test deliberately keeps a request (and + // thus its `writer_task_tx` registry handle) alive past a timeout, so it is + // the worst polluter of the checkpoint `tx_age_sweep_*` reads if left + // un-serialized. Shares the key — see the note on + // `begin_immediate_failure_replies_error_without_running_op`. #[tokio::test] + #[serial(tx_registry)] async fn send_with_timeout_returns_op_result_when_op_outlives_the_timeout() { // `send_with_timeout`'s timeout must bound ONLY the enqueue step — // never the reply-wait. An accepted request (channel not full) must From 1e789bb75d19bdef651fe5f9b8ecd2b7cc8ee5d3 Mon Sep 17 00:00:00 2001 From: Leo Date: Mon, 13 Jul 2026 17:56:25 -0400 Subject: [PATCH 5/5] test(kkernel): make code-ingest vector test hermetic --- crates/kkernel/src/code_ingest.rs | 81 +++++++++++++++++++++++++++++-- 1 file changed, 78 insertions(+), 3 deletions(-) diff --git a/crates/kkernel/src/code_ingest.rs b/crates/kkernel/src/code_ingest.rs index 4d0bf5d5e..2d49613eb 100644 --- a/crates/kkernel/src/code_ingest.rs +++ b/crates/kkernel/src/code_ingest.rs @@ -104,6 +104,16 @@ pub async fn run_code_ingest(args: CodeIngestArgs) -> Result<()> { /// after validation has already succeeded and a real (non-dry-run) write is /// about to occur. async fn code_ingest_batch(args: CodeIngestArgs) -> Result { + code_ingest_batch_with_runtime_setup(args, |_| Ok(())).await +} + +async fn code_ingest_batch_with_runtime_setup( + args: CodeIngestArgs, + runtime_setup: F, +) -> Result +where + F: FnOnce(&KhiveRuntime) -> Result<()>, +{ let bytes = std::fs::read(&args.findings) .with_context(|| format!("failed to read {}", args.findings.display()))?; @@ -161,6 +171,7 @@ async fn code_ingest_batch(args: CodeIngestArgs) -> Result { } let runtime = KhiveRuntime::new(cfg).map_err(|e| anyhow::anyhow!("{e}"))?; + runtime_setup(&runtime)?; let resolved_ns = runtime.config().default_namespace.clone(); let token = runtime .authorize(resolved_ns) @@ -479,10 +490,63 @@ fn wal_sidecar_path(db_path: &Path) -> PathBuf { #[cfg(test)] mod tests { + use std::sync::Arc; + + use async_trait::async_trait; + use khive_runtime::{EmbedderProvider, RuntimeError}; + use lattice_embed::{EmbedError, EmbeddingModel, EmbeddingService}; use serial_test::serial; use super::*; + struct FixedEmbeddingService { + dimensions: usize, + } + + #[async_trait] + impl EmbeddingService for FixedEmbeddingService { + async fn embed( + &self, + texts: &[String], + _model: EmbeddingModel, + ) -> std::result::Result>, EmbedError> { + Ok(texts + .iter() + .map(|_| vec![1.0_f32; self.dimensions]) + .collect()) + } + + fn supports_model(&self, _model: EmbeddingModel) -> bool { + true + } + + fn name(&self) -> &'static str { + "code-ingest-test" + } + } + + struct FixedEmbeddingProvider { + name: String, + dimensions: usize, + } + + #[async_trait] + impl EmbedderProvider for FixedEmbeddingProvider { + fn name(&self) -> &str { + &self.name + } + + fn dimensions(&self) -> usize { + self.dimensions + } + + async fn build(&self) -> std::result::Result, RuntimeError> { + Ok(Arc::new(FixedEmbeddingService { + dimensions: self.dimensions, + })) + } + } + fn base_args(findings: PathBuf, db: PathBuf) -> CodeIngestArgs { CodeIngestArgs { findings, @@ -847,9 +911,20 @@ mod tests { let findings = write_valid_findings(tmp.path()); let db = tmp.path().join("scratch.db"); - code_ingest_batch(base_args(findings, db.clone())) - .await - .expect("ingest must succeed"); + code_ingest_batch_with_runtime_setup(base_args(findings, db.clone()), |runtime| { + let model_names = runtime.registered_embedding_model_names(); + assert!( + !model_names.is_empty(), + "test requires at least one configured embedding model" + ); + for name in model_names { + let dimensions = runtime.resolve_embedding_model(Some(&name))?.dimensions(); + runtime.register_embedder(FixedEmbeddingProvider { name, dimensions }); + } + Ok(()) + }) + .await + .expect("ingest must succeed"); let cfg = resolve_runtime_config(RuntimeConfigInputs { db: Some(db.to_str().expect("utf8 path")),