From b36a72b183472c27d7a9c2aa4e836656e002feb7 Mon Sep 17 00:00:00 2001 From: CueCrux-Myles Date: Fri, 7 Aug 2026 14:04:42 +0100 Subject: [PATCH] fix(context): keep daemon-internal namespaces out of undirected recall On a fresh node, `GET /v1/context` answered a user's question with the daemon's own manual. Measured against a node holding exactly ONE user fact, authenticated with real scopes: no arguments 20 facts, 19 __bootstrap__::, 1 the user's entity= 1 fact, 0 1 query= 50 facts, 49 __bootstrap__::, 1 the user's So "what database do we use" returned 50 results, 49 of them Crux documentation. This is the injection surface an agent gets at session start, so it is the first thing a new user sees. Not an auth artifact -- it was first spotted with auth off, and the numbers above are with CORECRUXD_AUTH_MODE=dev_scopes and a scoped caller. `__bootstrap__::` IS forced private at ingest by enforce_global, but the seeder writes actor: None, and the bundle maps `private && actor.is_some()`, so the assembler's owner check never engages. The real gate is fetch-time visibility, which admits these to any authenticated caller. Fix: undirected recall (keyword, and the zero-hint default bundle) skips daemon-internal namespaces. Addressed recall does not, so nothing becomes unreachable -- `entity=__bootstrap__::doc:architecture` still returns it, and `get_bootstrap` (a separate path, untouched) remains the intended door to that content. The predicate lives in corecrux-memory::fact_privacy next to the prefix list that defines the convention, rather than being invented locally in one HTTP handler. It keys on the `__` marker: 37 of the 39 DEFAULT_PRIVATE_PREFIXES use it, and the two that do not (decisions::, github::) are private USER content rather than daemon bookkeeping -- which is exactly the line being drawn. Storage, export and audit paths must not use it; they need the complete set, and the doc comment says so. After: no-args 20 -> 1, query= 50 -> 1, addressed still 1. Tests: 2 regression tests on the context surface (undirected excludes, addressed still returns) + 2 on the predicate (covers every __ prefix; excludes user content). cargo test --workspace 7750 passed, 0 failed. fmt, clippy --workspace -D warnings, licence headers, unwrap ratchet clean. Found while building the M6 framework adapters: the adapters deliberately do NOT filter this client-side, because trimming results in an adapter is what their conformance suite forbids. It had to be fixed here or not at all. agent:claude-opus-5 Co-Authored-By: Claude Opus 5 (1M context) --- crates/corecrux-memory/src/fact_privacy.rs | 42 +++++++++++++++ crates/corecruxd/src/http/context_surface.rs | 55 ++++++++++++++++++++ 2 files changed, 97 insertions(+) diff --git a/crates/corecrux-memory/src/fact_privacy.rs b/crates/corecrux-memory/src/fact_privacy.rs index 75e0aeec..ec7fe0b8 100644 --- a/crates/corecrux-memory/src/fact_privacy.rs +++ b/crates/corecrux-memory/src/fact_privacy.rs @@ -140,6 +140,26 @@ pub const DEFAULT_PRIVATE_PREFIXES: &[&str] = &[ "github::", ]; +/// The marker that makes an entity a daemon-internal namespace: a leading +/// `__`, as in `__bootstrap__::`, `__coord__::`, `__work__::`. +/// +/// 37 of the 39 [`DEFAULT_PRIVATE_PREFIXES`] use it; the two that do not +/// (`decisions::`, `github::`) are private *user* content rather than daemon +/// bookkeeping, which is exactly the distinction this predicate draws. +pub const INTERNAL_NAMESPACE_MARKER: &str = "__"; + +/// Whether an entity lives in a daemon-internal namespace. +/// +/// Callers that assemble memory *for a human or a model* — recall surfaces, +/// injection bundles — should exclude these unless the caller named the +/// entity explicitly. They are the daemon's own bookkeeping and +/// documentation: real records, but not the user's memory, and on a fresh +/// node they outnumber it. Storage, export and audit paths must NOT use this; +/// they need the complete set. +pub fn is_internal_namespace(entity: &str) -> bool { + entity.starts_with(INTERNAL_NAMESPACE_MARKER) +} + /// Reserved entity namespaces owned exclusively by daemon governance flows. /// /// Client-facing fact-write handlers must reject these prefixes before they @@ -260,6 +280,28 @@ mod tests { } } + #[test] + fn internal_namespace_matches_the_reserved_prefixes() { + // Every `__`-prefixed default must be recognised, so a recall surface + // filtering on this predicate cannot miss one. + for prefix in DEFAULT_PRIVATE_PREFIXES.iter().filter(|p| p.starts_with("__")) { + assert!(is_internal_namespace(prefix), "{prefix} not detected as internal"); + } + assert!(is_internal_namespace("__bootstrap__::doc:api-append")); + assert!(is_internal_namespace("__coord__::session")); + } + + #[test] + fn internal_namespace_excludes_user_content() { + // These are private-by-default but they are the USER's records, not + // daemon bookkeeping — a recall surface must still return them. + assert!(!is_internal_namespace("decisions::pick-postgres")); + assert!(!is_internal_namespace("github::CueCrux/Crux")); + assert!(!is_internal_namespace("project:atlas")); + assert!(!is_internal_namespace("execplan:demo")); + assert!(!is_internal_namespace("_single_underscore::x")); + } + #[test] fn defaults_cover_known_internal_prefixes() { let p = PrivacyPolicy::from_prefixes( diff --git a/crates/corecruxd/src/http/context_surface.rs b/crates/corecruxd/src/http/context_surface.rs index c7a425fb..05b28f05 100644 --- a/crates/corecruxd/src/http/context_surface.rs +++ b/crates/corecruxd/src/http/context_surface.rs @@ -137,6 +137,16 @@ async fn gather_facts( // 2. Keyword recall, effective-confidence ranked (spec §4 rule 2) — // or the zero-hint default bundle (top facts overall). + // + // UNDIRECTED recall excludes daemon-internal namespaces (`__*::`). + // They are real records and they stay reachable — pass 1 above still + // resolves them when the caller names one — but they are the daemon's + // own bookkeeping and seeded documentation, not the user's memory, and + // on a fresh node they swamp it: measured 2026-08-07 against a node + // holding exactly one user fact, an authenticated `query=` returned 50 + // facts of which 49 were `__bootstrap__::` docs. A user asking "what + // database do we use" got the daemon's own manual. `get_bootstrap` is + // the intended door to that content. let keyword = req.query.as_deref().map(str::trim).filter(|q| !q.is_empty()); if keyword.is_some() || req.entity.is_none() { let q = corecrux_memory::fact_store::FactQuery { @@ -156,6 +166,9 @@ async fn gather_facts( if fact.superseded_by.is_some() || !seen.insert(fact.fact_id.clone()) { continue; } + if corecrux_memory::fact_privacy::is_internal_namespace(&fact.entity) { + continue; + } out.push(fact_input(fact, false)); } } @@ -650,6 +663,48 @@ mod tests { ); } + #[tokio::test] + async fn undirected_recall_excludes_daemon_internal_namespaces() { + // The regression this guards: on a fresh node the seeded + // `__bootstrap__::` docs outnumber the user's memory, so an + // undirected query answered with the daemon's own manual. + let state = enabled_state(); + store_fact(&state, "project:atlas", "database", "Postgres 16").await; + store_fact(&state, "__bootstrap__::doc:api-append", "content", "how to append").await; + store_fact(&state, "__coord__::session", "state", "internal bookkeeping").await; + + for request in [req(None, None, Some(4000)), req(None, Some("database"), Some(4000))] { + let bundle = get_bundle(&state, request).await; + let entities: Vec = facts_items(&bundle) + .iter() + .map(|f| f["entity"].as_str().unwrap_or_default().to_string()) + .collect(); + assert!( + entities.iter().any(|e| e == "project:atlas"), + "user fact missing from undirected recall: {entities:?}" + ); + assert!( + !entities.iter().any(|e| e.starts_with("__")), + "internal namespace leaked into undirected recall: {entities:?}" + ); + } + } + + #[tokio::test] + async fn addressing_an_internal_entity_still_returns_it() { + // Excluded from undirected recall, NOT hidden. A caller that names the + // entity still gets it, so nothing becomes unreachable. + let state = enabled_state(); + store_fact(&state, "__bootstrap__::doc:api-append", "content", "how to append").await; + + let bundle = get_bundle(&state, req(Some("__bootstrap__::doc:api-append"), None, Some(4000))).await; + let entities: Vec = facts_items(&bundle) + .iter() + .map(|f| f["entity"].as_str().unwrap_or_default().to_string()) + .collect(); + assert_eq!(entities, vec!["__bootstrap__::doc:api-append".to_string()]); + } + #[tokio::test] async fn stable_region_is_byte_stable_across_calls() { let state = enabled_state();