Skip to content
Merged
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
18 changes: 18 additions & 0 deletions crates/khive-db/src/writer_task.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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
Expand Down
12 changes: 11 additions & 1 deletion crates/khive-mcp/src/serve.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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}\")");
Expand Down
13 changes: 11 additions & 2 deletions crates/khive-pack-brain/src/handlers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 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
// slice 2 — it is boxed into an `AtomicUnitOp` and may run
Expand All @@ -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(),
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
97 changes: 97 additions & 0 deletions crates/khive-pack-brain/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6613,3 +6613,100 @@ 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,
) -> String {
let registry = empty_registry();
let target = create_test_entity(rt, token).await;
let result = pack
.dispatch(
"brain.feedback",
json!({"target_id": target, "signal": signal}),
&registry,
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, "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, "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, "anonymous:local",
"unresolved actor must fall back to an explicit label, never the literal \"brain\""
);
}
}
81 changes: 78 additions & 3 deletions crates/kkernel/src/code_ingest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<CodeIngestReport> {
code_ingest_batch_with_runtime_setup(args, |_| Ok(())).await
}

async fn code_ingest_batch_with_runtime_setup<F>(
args: CodeIngestArgs,
runtime_setup: F,
) -> Result<CodeIngestReport>
where
F: FnOnce(&KhiveRuntime) -> Result<()>,
{
let bytes = std::fs::read(&args.findings)
.with_context(|| format!("failed to read {}", args.findings.display()))?;

Expand Down Expand Up @@ -161,6 +171,7 @@ async fn code_ingest_batch(args: CodeIngestArgs) -> Result<CodeIngestReport> {
}

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)
Expand Down Expand Up @@ -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<Vec<Vec<f32>>, 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<Arc<dyn EmbeddingService>, RuntimeError> {
Ok(Arc::new(FixedEmbeddingService {
dimensions: self.dimensions,
}))
}
}

fn base_args(findings: PathBuf, db: PathBuf) -> CodeIngestArgs {
CodeIngestArgs {
findings,
Expand Down Expand Up @@ -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")),
Expand Down
Loading