diff --git a/apps/elf-api/src/routes.rs b/apps/elf-api/src/routes.rs index 1deda8cb..a391d9f4 100644 --- a/apps/elf-api/src/routes.rs +++ b/apps/elf-api/src/routes.rs @@ -3,6 +3,7 @@ mod admin_notes; mod admin_ops; mod consolidation; +mod context_pack; mod contract; mod core_memory; mod docs; @@ -73,28 +74,28 @@ use elf_service::{ ConsolidationProposalReviewRequest, ConsolidationProposalsListRequest, ConsolidationProposalsListResponse, ConsolidationRunCreateRequest, ConsolidationRunCreateResponse, ConsolidationRunGetRequest, ConsolidationRunResponse, - ConsolidationRunsListRequest, ConsolidationRunsListResponse, CoreBlockAttachRequest, - CoreBlockAttachResponse, CoreBlockDetachRequest, CoreBlockDetachResponse, - CoreBlockUpsertRequest, CoreBlockUpsertResponse, CoreBlocksGetRequest, CoreBlocksResponse, - DeleteRequest, DeleteResponse, DocType, DocsDeleteRequest, DocsDeleteResponse, - DocsExcerptResponse, DocsExcerptsGetRequest, DocsGetRequest, DocsGetResponse, DocsPutRequest, - DocsPutResponse, DocsSearchL0Request, DocsSearchL0Response, DreamingReviewQueueRequest, - DreamingReviewQueueResponse, EntityMemoryViewRequest, EntityMemoryViewResponse, Error, - EventMessage, GranteeKind, GraphQueryEntityRef, GraphQueryPredicateRef, GraphQueryRequest, - GraphQueryResponse, GraphReportRequest, GraphReportResponse, IngestionProfileSelector, - KnowledgePageChangedSource, KnowledgePageGetRequest, KnowledgePageLintRequest, - KnowledgePageLintResponse, KnowledgePageRebuildRequest, KnowledgePageRebuildResponse, - KnowledgePageResponse, KnowledgePageSearchRequest, KnowledgePageSearchResponse, - KnowledgePageWatchRebuildRequest, KnowledgePageWatchRebuildResponse, KnowledgePagesListRequest, - KnowledgePagesListResponse, ListRequest, ListResponse, MemoryCorrectionAction, - MemoryCorrectionRequest, MemoryCorrectionResponse, MemoryHistoryGetRequest, - MemoryHistoryResponse, NoteFetchRequest, NoteFetchResponse, NoteProvenanceBundleResponse, - NoteProvenanceGetRequest, PayloadLevel, PublishNoteRequest, QueryPlan, RankingRequestOverride, - RebuildReport, RecallDebugPanelRequest, RecallDebugPanelResponse, SearchDetailsRequest, - SearchDetailsResult, SearchExplainRequest, SearchExplainResponse, SearchIndexItem, - SearchRequest, SearchResponse, SearchSessionGetRequest, SearchTimelineGroup, - SearchTimelineRequest, SearchTrajectoryResponse, SearchTrajectorySummary, ShareScope, - SpaceGrantRevokeRequest, SpaceGrantRevokeResponse, SpaceGrantUpsertRequest, + ConsolidationRunsListRequest, ConsolidationRunsListResponse, ContextPackRequest, + ContextPackResponse, CoreBlockAttachRequest, CoreBlockAttachResponse, CoreBlockDetachRequest, + CoreBlockDetachResponse, CoreBlockUpsertRequest, CoreBlockUpsertResponse, CoreBlocksGetRequest, + CoreBlocksResponse, DeleteRequest, DeleteResponse, DocType, DocsDeleteRequest, + DocsDeleteResponse, DocsExcerptResponse, DocsExcerptsGetRequest, DocsGetRequest, + DocsGetResponse, DocsPutRequest, DocsPutResponse, DocsSearchL0Request, DocsSearchL0Response, + DreamingReviewQueueRequest, DreamingReviewQueueResponse, EntityMemoryViewRequest, + EntityMemoryViewResponse, Error, EventMessage, GranteeKind, GraphQueryEntityRef, + GraphQueryPredicateRef, GraphQueryRequest, GraphQueryResponse, GraphReportRequest, + GraphReportResponse, IngestionProfileSelector, KnowledgePageChangedSource, + KnowledgePageGetRequest, KnowledgePageLintRequest, KnowledgePageLintResponse, + KnowledgePageRebuildRequest, KnowledgePageRebuildResponse, KnowledgePageResponse, + KnowledgePageSearchRequest, KnowledgePageSearchResponse, KnowledgePageWatchRebuildRequest, + KnowledgePageWatchRebuildResponse, KnowledgePagesListRequest, KnowledgePagesListResponse, + ListRequest, ListResponse, MemoryCorrectionAction, MemoryCorrectionRequest, + MemoryCorrectionResponse, MemoryHistoryGetRequest, MemoryHistoryResponse, NoteFetchRequest, + NoteFetchResponse, NoteProvenanceBundleResponse, NoteProvenanceGetRequest, PayloadLevel, + PublishNoteRequest, QueryPlan, RankingRequestOverride, RebuildReport, RecallDebugPanelRequest, + RecallDebugPanelResponse, SearchDetailsRequest, SearchDetailsResult, SearchExplainRequest, + SearchExplainResponse, SearchIndexItem, SearchRequest, SearchResponse, SearchSessionGetRequest, + SearchTimelineGroup, SearchTimelineRequest, SearchTrajectoryResponse, SearchTrajectorySummary, + ShareScope, SpaceGrantRevokeRequest, SpaceGrantRevokeResponse, SpaceGrantUpsertRequest, SpaceGrantsListRequest, TextPositionSelector, TextQuoteSelector, TraceBundleGetRequest, TraceBundleResponse, TraceGetRequest, TraceGetResponse, TraceRecentListRequest, TraceRecentListResponse, TraceTrajectoryGetRequest, UnpublishNoteRequest, UpdateRequest, @@ -117,16 +118,16 @@ use types::{ AdminIngestionProfileCreateBody, AdminIngestionProfileDefaultResponseV2, AdminIngestionProfileDefaultSetBody, AdminIngestionProfileGetQuery, AdminNoteCorrectionBody, ConsolidationProposalReviewBody, ConsolidationProposalsListQuery, ConsolidationRunCreateBody, - ConsolidationRunsListQuery, CoreBlockAttachBody, CoreBlockUpsertBody, DocsExcerptsGetBody, - DocsPutBody, DocsSearchL0Body, DreamingReviewQueueQuery, ErrorBody, EventsIngestRequest, - GraphQueryBody, GraphReportBody, KnowledgePageRebuildBody, KnowledgePageWatchRebuildBody, - KnowledgePagesListQuery, KnowledgePagesSearchBody, NotePatchRequest, NotesIngestRequest, - NotesListQuery, PublishResponseV2, RecallDebugPanelBody, SearchCreateRequest, - SearchCreateResponseV2, SearchDetailsBody, SearchDetailsResponseV2, SearchIndexResponseV2, - SearchSessionGetQuery, SearchTimelineQuery, SearchTimelineResponseV2, ShareScopeBody, - SpaceGrantItemV2, SpaceGrantUpsertBody, SpaceGrantUpsertResponseV2, SpaceGrantsListResponseV2, - TraceBundleGetQuery, TraceRecentListQuery, WorkJournalEntryCreateBody, - WorkJournalSessionReadbackBody, + ConsolidationRunsListQuery, ContextPackBody, CoreBlockAttachBody, CoreBlockUpsertBody, + DocsExcerptsGetBody, DocsPutBody, DocsSearchL0Body, DreamingReviewQueueQuery, ErrorBody, + EventsIngestRequest, GraphQueryBody, GraphReportBody, KnowledgePageRebuildBody, + KnowledgePageWatchRebuildBody, KnowledgePagesListQuery, KnowledgePagesSearchBody, + NotePatchRequest, NotesIngestRequest, NotesListQuery, PublishResponseV2, RecallDebugPanelBody, + SearchCreateRequest, SearchCreateResponseV2, SearchDetailsBody, SearchDetailsResponseV2, + SearchIndexResponseV2, SearchSessionGetQuery, SearchTimelineQuery, SearchTimelineResponseV2, + ShareScopeBody, SpaceGrantItemV2, SpaceGrantUpsertBody, SpaceGrantUpsertResponseV2, + SpaceGrantsListResponseV2, TraceBundleGetQuery, TraceRecentListQuery, + WorkJournalEntryCreateBody, WorkJournalSessionReadbackBody, }; #[cfg(test)] use viewer::VIEWER_HTML; diff --git a/apps/elf-api/src/routes/context_pack.rs b/apps/elf-api/src/routes/context_pack.rs new file mode 100644 index 00000000..c2a35efd --- /dev/null +++ b/apps/elf-api/src/routes/context_pack.rs @@ -0,0 +1,59 @@ +use crate::routes::{ + self, ApiError, AppState, ContextPackBody, ContextPackRequest, ContextPackResponse, ErrorBody, + HeaderMap, Json, JsonRejection, RequestContext, State, StatusCode, +}; + +#[utoipa::path( + post, + path = "/v2/context-packs", + tag = "context-pack", + request_body = Value, + responses( + (status = 200, description = "Read-time Context Pack v1.", body = Value), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Scope denied.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] +pub(super) async fn context_pack_build( + State(state): State, + headers: HeaderMap, + payload: Result, JsonRejection>, +) -> Result, ApiError> { + let ctx = RequestContext::from_headers(&headers)?; + let read_profile = routes::required_read_profile(&headers)?; + let Json(payload) = payload.map_err(|err| { + tracing::warn!(error = %err, "Invalid request payload."); + + routes::json_error( + StatusCode::BAD_REQUEST, + "INVALID_REQUEST", + "Invalid request payload.", + None, + ) + })?; + let response = state + .service + .context_pack_build(ContextPackRequest { + tenant_id: ctx.tenant_id, + project_id: ctx.project_id, + agent_id: ctx.agent_id, + read_profile, + task: payload.task, + title: payload.title, + description: payload.description, + trace_id: payload.trace_id, + query: payload.query, + docs_query: payload.docs_query, + knowledge_query: payload.knowledge_query, + graph_subject: payload.graph_subject, + graph_predicate: payload.graph_predicate, + include_dreaming: payload.include_dreaming, + limit: payload.limit, + debug_overrides: None, + }) + .await?; + + Ok(Json(response)) +} diff --git a/apps/elf-api/src/routes/contract.rs b/apps/elf-api/src/routes/contract.rs index cc4e4ca8..bf313ace 100644 --- a/apps/elf-api/src/routes/contract.rs +++ b/apps/elf-api/src/routes/contract.rs @@ -18,6 +18,7 @@ use crate::routes::{ __path_consolidation_proposals_list, __path_consolidation_run_create, __path_consolidation_run_get, __path_consolidation_runs_list, }, + context_pack::__path_context_pack_build, core_memory::{ __path_admin_core_block_attach, __path_admin_core_block_detach, __path_admin_core_block_upsert, __path_core_blocks_get, __path_entity_memory_get, @@ -92,6 +93,7 @@ pub const SCALAR_DOCS_PATH: &str = "/docs"; docs_excerpts_get, core_blocks_get, entity_memory_get, + context_pack_build, admin_core_block_upsert, admin_core_block_attach, admin_core_block_detach, diff --git a/apps/elf-api/src/routes/route_builder/public.rs b/apps/elf-api/src/routes/route_builder/public.rs index 4124607a..8e7ddf01 100644 --- a/apps/elf-api/src/routes/route_builder/public.rs +++ b/apps/elf-api/src/routes/route_builder/public.rs @@ -9,6 +9,7 @@ pub(super) fn public_api_router() -> Router { .route("/v2/events/ingest", routing::post(routes::events::events_ingest)) .route("/v2/core-blocks", routing::get(routes::core_memory::core_blocks_get)) .route("/v2/entity-memory", routing::get(routes::core_memory::entity_memory_get)) + .route("/v2/context-packs", routing::post(routes::context_pack::context_pack_build)) .route("/v2/recall-debug/panel", routing::post(routes::recall::recall_debug_panel)) .route("/v2/searches", routing::post(routes::search::searches_create)) .route("/v2/searches/{search_id}", routing::get(routes::search::searches_get)) diff --git a/apps/elf-api/src/routes/types.rs b/apps/elf-api/src/routes/types.rs index 500cbc2e..e15c3063 100644 --- a/apps/elf-api/src/routes/types.rs +++ b/apps/elf-api/src/routes/types.rs @@ -1,4 +1,5 @@ mod consolidation; +mod context_pack; mod core_memory; mod docs; mod errors; @@ -18,6 +19,7 @@ pub(in crate::routes) use self::{ ConsolidationProposalReviewBody, ConsolidationProposalsListQuery, ConsolidationRunCreateBody, ConsolidationRunsListQuery, DreamingReviewQueueQuery, }, + context_pack::ContextPackBody, core_memory::{CoreBlockAttachBody, CoreBlockUpsertBody}, docs::{DocsExcerptsGetBody, DocsPutBody, DocsSearchL0Body}, errors::ErrorBody, diff --git a/apps/elf-api/src/routes/types/context_pack.rs b/apps/elf-api/src/routes/types/context_pack.rs new file mode 100644 index 00000000..9b5f605a --- /dev/null +++ b/apps/elf-api/src/routes/types/context_pack.rs @@ -0,0 +1,16 @@ +use crate::routes::types::{Deserialize, GraphQueryEntityRef, GraphQueryPredicateRef, Uuid}; + +#[derive(Clone, Debug, Deserialize)] +pub(in crate::routes) struct ContextPackBody { + pub(in crate::routes) task: String, + pub(in crate::routes) title: Option, + pub(in crate::routes) description: Option, + pub(in crate::routes) trace_id: Option, + pub(in crate::routes) query: Option, + pub(in crate::routes) docs_query: Option, + pub(in crate::routes) knowledge_query: Option, + pub(in crate::routes) graph_subject: Option, + pub(in crate::routes) graph_predicate: Option, + pub(in crate::routes) include_dreaming: Option, + pub(in crate::routes) limit: Option, +} diff --git a/apps/elf-mcp/src/app/server.rs b/apps/elf-mcp/src/app/server.rs index f9eec358..8b267ec7 100644 --- a/apps/elf-mcp/src/app/server.rs +++ b/apps/elf-mcp/src/app/server.rs @@ -10,9 +10,9 @@ use rmcp::handler::server::router::tool::ToolRouter; #[cfg(test)] use schemas::{ - docs_excerpts_get_schema, docs_put_schema, docs_search_l0_schema, notes_ingest_schema, - recall_debug_panel_schema, searches_create_schema, searches_get_schema, searches_notes_schema, - searches_timeline_schema, work_journal_entry_create_schema, + context_pack_build_schema, docs_excerpts_get_schema, docs_put_schema, docs_search_l0_schema, + notes_ingest_schema, recall_debug_panel_schema, searches_create_schema, searches_get_schema, + searches_notes_schema, searches_timeline_schema, work_journal_entry_create_schema, work_journal_session_readback_schema, }; use state::{ElfContextHeaders, ElfMcp, HttpMethod}; diff --git a/apps/elf-mcp/src/app/server/schemas.rs b/apps/elf-mcp/src/app/server/schemas.rs index 5e8cbc3b..58eb3c81 100644 --- a/apps/elf-mcp/src/app/server/schemas.rs +++ b/apps/elf-mcp/src/app/server/schemas.rs @@ -21,8 +21,8 @@ pub(in crate::app::server) use self::{ events::events_ingest_schema, graph::{graph_query_schema, graph_report_schema}, memory::{ - core_blocks_get_schema, dreaming_review_queue_schema, entity_memory_get_schema, - recall_debug_panel_schema, + context_pack_build_schema, core_blocks_get_schema, dreaming_review_queue_schema, + entity_memory_get_schema, recall_debug_panel_schema, }, notes::{ notes_get_schema, notes_ingest_schema, notes_list_schema, notes_patch_schema, diff --git a/apps/elf-mcp/src/app/server/schemas/memory.rs b/apps/elf-mcp/src/app/server/schemas/memory.rs index 0dc1dc09..50fac84d 100644 --- a/apps/elf-mcp/src/app/server/schemas/memory.rs +++ b/apps/elf-mcp/src/app/server/schemas/memory.rs @@ -109,3 +109,74 @@ pub(in crate::app::server) fn recall_debug_panel_schema() -> Arc { } })) } + +pub(in crate::app::server) fn context_pack_build_schema() -> Arc { + Arc::new(rmcp::object!({ + "type": "object", + "additionalProperties": false, + "required": ["task"], + "properties": { + "task": { "type": "string" }, + "title": { "type": ["string", "null"] }, + "description": { "type": ["string", "null"] }, + "trace_id": { "type": ["string", "null"], "format": "uuid" }, + "query": { "type": ["string", "null"] }, + "docs_query": { "type": ["string", "null"] }, + "knowledge_query": { "type": ["string", "null"] }, + "graph_subject": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": ["entity_id"], + "properties": { + "entity_id": { + "type": "string", + "format": "uuid" + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["surface"], + "properties": { + "surface": { "type": "string" } + } + }, + { "type": "null" } + ] + }, + "graph_predicate": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": ["predicate_id"], + "properties": { + "predicate_id": { + "type": "string", + "format": "uuid" + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["surface"], + "properties": { + "surface": { "type": "string" } + } + }, + { "type": "null" } + ] + }, + "include_dreaming": { "type": ["boolean", "null"] }, + "limit": { + "type": ["integer", "null"], + "minimum": 1, + "maximum": 50 + } + } + })) +} diff --git a/apps/elf-mcp/src/app/server/tests/schemas.rs b/apps/elf-mcp/src/app/server/tests/schemas.rs index 67c42dda..be6194aa 100644 --- a/apps/elf-mcp/src/app/server/tests/schemas.rs +++ b/apps/elf-mcp/src/app/server/tests/schemas.rs @@ -1,3 +1,4 @@ +mod context_pack; mod docs; mod notes; mod recall_debug; diff --git a/apps/elf-mcp/src/app/server/tests/schemas/context_pack.rs b/apps/elf-mcp/src/app/server/tests/schemas/context_pack.rs new file mode 100644 index 00000000..aea19204 --- /dev/null +++ b/apps/elf-mcp/src/app/server/tests/schemas/context_pack.rs @@ -0,0 +1,47 @@ +use serde_json::Value; + +use crate::app::server; + +#[test] +fn context_pack_schema_rejects_context_override_fields() { + let schema = server::context_pack_build_schema(); + let properties = schema + .get("properties") + .and_then(Value::as_object) + .expect("context pack schema is missing properties."); + let required = schema + .get("required") + .and_then(Value::as_array) + .expect("context pack schema is missing required fields."); + + assert_eq!(schema.get("additionalProperties"), Some(&Value::Bool(false))); + assert!(required.iter().any(|value| value.as_str() == Some("task"))); + + for key in ["tenant_id", "project_id", "agent_id", "read_profile"] { + assert!(!properties.contains_key(key), "{key} must not be a tool param."); + } + + assert!( + !properties.contains_key("debug_overrides"), + "debug overrides must not be public MCP params." + ); + + for key in ["graph_subject", "graph_predicate"] { + let one_of = properties + .get(key) + .and_then(Value::as_object) + .and_then(|schema| schema.get("oneOf")) + .and_then(Value::as_array) + .expect("selector schema is missing oneOf."); + + for branch in one_of.iter().filter_map(Value::as_object) { + if branch.get("type").and_then(Value::as_str) == Some("object") { + assert_eq!( + branch.get("additionalProperties"), + Some(&Value::Bool(false)), + "{key} selector object branches must be closed." + ); + } + } + } +} diff --git a/apps/elf-mcp/src/app/server/tests/tool_definitions.rs b/apps/elf-mcp/src/app/server/tests/tool_definitions.rs index 9285e642..d9e90e90 100644 --- a/apps/elf-mcp/src/app/server/tests/tool_definitions.rs +++ b/apps/elf-mcp/src/app/server/tests/tool_definitions.rs @@ -1,5 +1,6 @@ pub(super) mod catalog; +mod context_pack; mod recall_debug; mod registration; mod search_payload; diff --git a/apps/elf-mcp/src/app/server/tests/tool_definitions/catalog.rs b/apps/elf-mcp/src/app/server/tests/tool_definitions/catalog.rs index 2245f48e..309e6fec 100644 --- a/apps/elf-mcp/src/app/server/tests/tool_definitions/catalog.rs +++ b/apps/elf-mcp/src/app/server/tests/tool_definitions/catalog.rs @@ -2,7 +2,7 @@ use std::collections::HashMap; use crate::app::server::HttpMethod; -const ALL_TOOL_DEFINITIONS: [ToolDefinition; 37] = [ +const ALL_TOOL_DEFINITIONS: [ToolDefinition; 38] = [ ToolDefinition::new( "elf_notes_ingest", HttpMethod::Post, @@ -51,6 +51,12 @@ const ALL_TOOL_DEFINITIONS: [ToolDefinition; 37] = [ "/v2/admin/dreaming/review-queue", "List source-backed Dreaming review queue proposals with variants, affected refs, lint flags, policy gates, and review audit.", ), + ToolDefinition::new( + "elf_context_pack_build", + HttpMethod::Post, + "/v2/context-packs", + "Build an ephemeral Context Pack v1 as a read-time scoped view with automatic layer routing, source-backed item refs, and activation trace.", + ), ToolDefinition::new( "elf_recall_debug_panel", HttpMethod::Post, diff --git a/apps/elf-mcp/src/app/server/tests/tool_definitions/context_pack.rs b/apps/elf-mcp/src/app/server/tests/tool_definitions/context_pack.rs new file mode 100644 index 00000000..30462335 --- /dev/null +++ b/apps/elf-mcp/src/app/server/tests/tool_definitions/context_pack.rs @@ -0,0 +1,10 @@ +use crate::app::server::tests::tool_definitions::catalog; + +#[test] +fn context_pack_tool_uses_public_agent_route() { + let tools = catalog::build_tools(); + let tool = tools.get("elf_context_pack_build").expect("Missing context pack tool."); + + assert_eq!(tool.path, "/v2/context-packs"); + assert!(tool.description.contains("read-time scoped view")); +} diff --git a/apps/elf-mcp/src/app/server/tests/tool_definitions/registration.rs b/apps/elf-mcp/src/app/server/tests/tool_definitions/registration.rs index 11602b4c..f5ad8c92 100644 --- a/apps/elf-mcp/src/app/server/tests/tool_definitions/registration.rs +++ b/apps/elf-mcp/src/app/server/tests/tool_definitions/registration.rs @@ -25,6 +25,7 @@ fn registers_all_tools() { "elf_space_grant_revoke", "elf_admin_traces_recent_list", "elf_dreaming_review_queue", + "elf_context_pack_build", "elf_recall_debug_panel", "elf_work_journal_entry_create", "elf_work_journal_entry_get", diff --git a/apps/elf-mcp/src/app/server/tools/core/memory.rs b/apps/elf-mcp/src/app/server/tools/core/memory.rs index 88c487e1..245c29af 100644 --- a/apps/elf-mcp/src/app/server/tools/core/memory.rs +++ b/apps/elf-mcp/src/app/server/tools/core/memory.rs @@ -7,9 +7,9 @@ use rmcp::{ use crate::app::server::{ ElfMcp, HttpMethod, schemas::{ - core_blocks_get_schema, dreaming_review_queue_schema, entity_memory_get_schema, - recall_debug_panel_schema, work_journal_entry_create_schema, work_journal_entry_get_schema, - work_journal_session_readback_schema, + context_pack_build_schema, core_blocks_get_schema, dreaming_review_queue_schema, + entity_memory_get_schema, recall_debug_panel_schema, work_journal_entry_create_schema, + work_journal_entry_get_schema, work_journal_session_readback_schema, }, support, }; @@ -100,6 +100,20 @@ impl ElfMcp { self.forward(HttpMethod::Get, "/v2/admin/dreaming/review-queue", params, None).await } + #[rmcp::tool( + name = "elf_context_pack_build", + description = "Build an ephemeral Context Pack v1 as a read-time scoped view with automatic layer routing, source-backed item refs, and activation trace. This does not create memory.", + input_schema = context_pack_build_schema() + )] + pub(in crate::app::server) async fn elf_context_pack_build( + &self, + params: JsonObject, + ) -> Result { + support::reject_context_override_params(¶ms)?; + + self.forward(HttpMethod::Post, "/v2/context-packs", params, None).await + } + #[rmcp::tool( name = "elf_recall_debug_panel", description = "Build an agent-facing cross-layer recall/debug panel and deterministic recall_trace over memory traces, source documents, knowledge pages, graph facts, and Dreaming proposals.", diff --git a/docs/log.md b/docs/log.md index df953b68..ffbcd932 100644 --- a/docs/log.md +++ b/docs/log.md @@ -173,3 +173,9 @@ logs. - Added the XY-1153 Knowledge Workspace authority-boundary marker for changed-source memory candidates so derived page deltas remain reviewable consolidation proposals and cannot directly mutate Memory Authority or source evidence. +- Added `docs/spec/system_context_pack_v1.md` for XY-1154, defining + `elf.context_pack/v1`, automatic routing, debug-only enable/disable/pin overrides, + read-time-only pack assembly, source-ref/freshness eligibility, and + `elf.context_pack.routing_trace/v1` privacy boundaries. +- Linked Context Pack v1 from the spec index, version registry, source-backed product + contract, ELF v2 HTTP endpoint map, and MCP tool map. diff --git a/docs/spec/agent_memory_knowledge_system_v1.md b/docs/spec/agent_memory_knowledge_system_v1.md index f875151c..495f0b1f 100644 --- a/docs/spec/agent_memory_knowledge_system_v1.md +++ b/docs/spec/agent_memory_knowledge_system_v1.md @@ -24,6 +24,7 @@ related: - docs/spec/system_elf_memory_service_v2.md - docs/spec/system_consolidation_proposals_v1.md - docs/spec/system_knowledge_pages_v1.md + - docs/spec/system_context_pack_v1.md - docs/spec/system_recall_debug_panel_v1.md - docs/spec/system_graph_memory_postgres_v1.md - docs/spec/system_memory_summary_v1.md diff --git a/docs/spec/index.md b/docs/spec/index.md index 4e4a15ca..cbf5e7ad 100644 --- a/docs/spec/index.md +++ b/docs/spec/index.md @@ -38,6 +38,7 @@ Question this index answers: "what must remain true?" - `real_world_agent_memory_benchmark_v1.md`: Real-World Agent Memory Benchmark v1. - `system_competitive_parity_gate_v1.md`: Competitive Parity Gate v1 Specification. - `system_consolidation_proposals_v1.md`: Consolidation Proposals v1 Specification. +- `system_context_pack_v1.md`: Context Pack v1 Specification. - `system_doc_chunking_profiles_v1.md`: System: `doc_chunking_profiles/v1` for `docs_put`. - `system_doc_extension_v1_filters.md`: System: Document Extension v1 Filter and Payload Contract. - `system_doc_extension_v1_trajectory.md`: System: Doc Extension v1 Retrieval Trajectory (`doc_retrieval_trajectory/v1`). diff --git a/docs/spec/system_context_pack_v1.md b/docs/spec/system_context_pack_v1.md new file mode 100644 index 00000000..313c3b3d --- /dev/null +++ b/docs/spec/system_context_pack_v1.md @@ -0,0 +1,191 @@ +--- +type: Spec +title: "Context Pack v1 Specification" +description: "Define the read-time Context Pack v1 contract, routing trace, and privacy boundaries." +resource: docs/spec/system_context_pack_v1.md +status: active +authority: normative +owner: spec +last_verified: 2026-07-03 +tags: + - docs + - spec + - context-pack +source_refs: + - https://linear.app/hack-ink/issue/XY-1154/implement-context-pack-v1-automatic-routing-recall-engine-and-recall-debug +code_refs: + - packages/elf-service/src/context_pack.rs + - apps/elf-api/src/routes/context_pack.rs + - apps/elf-mcp/src/app/server/tools/core/memory.rs +related: + - docs/spec/agent_memory_knowledge_system_v1.md + - docs/spec/system_recall_debug_panel_v1.md + - docs/spec/system_elf_memory_service_v2.md +drift_watch: + - packages/elf-service/src/context_pack.rs + - apps/elf-api/src/routes/context_pack.rs + - apps/elf-mcp/src/app/server/tools/core/memory.rs +--- +# Context Pack v1 Specification + +Purpose: Define the read-time Context Pack v1 contract, routing trace, and privacy boundaries. +Status: normative +Read this when: You are implementing, validating, or reviewing Context Pack routing, +pack assembly, API/MCP exposure, or Recall Debug trace integration. +Not this document: Low-level search ranking, source capture, memory writes, or +operator procedures. +Defines: `elf.context_pack/v1`, `elf.context_pack.routing_trace/v1`, automatic +layer routing, pin/override limits, freshness suppression, and read-time privacy. + +## Boundary + +Context Packs are ephemeral read-time scoped views. They assemble bounded, cited +context from current readable recall layers and expire with the response. Context +Pack creation must not insert, update, delete, promote, correct, index, or persist +Memory Authority notes, Source Library documents, Knowledge Workspace pages, Work +Journal entries, graph facts, Dreaming proposals, search traces, or Qdrant points. + +The public HTTP route is: + +- `POST /v2/context-packs` + +The MCP tool is: + +- `elf_context_pack_build` + +Both surfaces are read-only facades over `elf-service`. The HTTP route derives +tenant, project, agent, and read profile from request headers. The MCP tool must not +accept tenant, project, agent, or read-profile override fields; it uses the MCP +server-configured context headers. + +## Request + +The request body accepts: + +- `task` (required): English task text used by automatic routing. +- `title`, `description` (optional): display metadata. +- `trace_id` (optional): Memory Notes trace anchor. +- `query` (optional): shared query fallback for document and knowledge selectors. +- `docs_query` (optional): Source Library selector. +- `knowledge_query` (optional): Knowledge Workspace selector. +- `graph_subject`, `graph_predicate` (optional): graph selector. +- `include_dreaming` (optional): Dreaming proposal selector. +- `limit` (optional): max pack items, clamped to the service maximum. + +Request context is never read from the body. + +Service-level debug/test/admin callers may use internal route controls for +`enable_layers`, `disable_layers`, and `pin_layers`. Public HTTP and MCP Context Pack +requests must not expose those controls. + +## Response + +The response schema is `elf.context_pack/v1`. + +Required top-level fields: + +- `pack_id`: generated UUID for this ephemeral response. +- `schema`: `elf.context_pack/v1`. +- `version`: `1`. +- `title` and `description`. +- `activation_policy`. +- `authority_layers`. +- `typed_selectors`. +- `required_anchors`. +- `read_profile_policy`. +- `freshness_policy`. +- `budget_limits`. +- `ranking_policy`. +- `debug_policy`. +- `routing_trace`. +- `items`. +- `recall_trace`. +- `generated_at` and `expires_at`. + +Pack items carry only references and metadata returned by readable current recall +rows: + +- `layer` +- `authority_layer` +- `freshness_state` +- `item_ref` +- `source_refs` +- `score` +- `rank` +- `reason_code` +- `pinned_priority` + +Context Pack items are not facts, summaries, or durable memory. Callers must treat +them as transport references back to the owning authority layer. + +## Automatic Routing + +Default routing is automatic: + +- `trace_id` selects the Memory Notes layer. +- `docs_query`, or `query`, or non-empty `task` selects Source Library search. +- `knowledge_query`, or `query`, or non-empty `task` selects Knowledge Workspace search. +- `graph_subject` selects graph facts. +- `include_dreaming = true`, or task text containing Dreaming/review/proposal intent, + selects Dreaming proposals. + +Manual enable, disable, and pin controls are override/debug/test/admin aids only and +are not public agent request fields. When a trusted internal/admin caller uses them, +they must be recorded in selectors and routing trace. A disabled layer is suppressed. +An enabled layer still requires its required anchor. A pinned layer may move eligible +items earlier in pack ordering, but pinning cannot bypass scope, read profile, grants, +freshness, deletion, redaction, authority, evidence, or required-anchor checks. + +## Routing Trace + +The routing trace schema is `elf.context_pack.routing_trace/v1`. + +Each entry includes: + +- `layer` +- `activation_state` +- `reason_code` +- `policy_reason` +- `manual_override` +- `pinned` +- `privacy` + +Useful `activation_state` values include: + +- `selected` +- `suppressed` +- `blocked` +- `not_requested` +- `pinned_ineligible` + +Pinned but ineligible layers must be represented as suppressed or +`pinned_ineligible`; they must not expose unreadable source counts, refs, or content. + +## Recall And Freshness + +Context Pack assembly uses the Recall Debug service read model. The pack response +must include the underlying `elf.recall_trace/v1` projection so callers can inspect +selected, dropped, stale, blocked, suppressed, and not-requested context. + +Pack item eligibility requires all of the following: + +- the row selection state is `selected`, `available`, or `reviewable`; +- the row evidence class is `pass`; +- source refs or source snapshots are present; +- freshness is current, not deleted, deprecated, expired, stale, superseded, + tombstoned, or historical; +- the underlying layer already applied read-profile, grant, lifecycle, redaction, + and authority checks. + +Rows that fail eligibility may remain visible in `recall_trace` when the underlying +Recall Debug contract allows it, but they must not become Context Pack items. + +## Privacy + +Context Pack debug output must not leak unreadable/private source existence, counts, +refs, or content. Public memory-note refs must resolve through active, unexpired, +readable `memory_notes` at read time before pack assembly. Deleted, deprecated, +expired, ungranted, or private rows must not be hydrated into pack `items`. + +Blocked and not-requested states should remain typed evidence instead of being +collapsed into pass claims. diff --git a/docs/spec/system_elf_memory_service_v2.md b/docs/spec/system_elf_memory_service_v2.md index b7d19aea..646e6c85 100644 --- a/docs/spec/system_elf_memory_service_v2.md +++ b/docs/spec/system_elf_memory_service_v2.md @@ -1176,10 +1176,27 @@ Behavior: through provenance/history or explicit non-active list filters, not ordinary search. Recall/debug panel: +- POST /v2/context-packs - POST /v2/recall-debug/panel - POST /v2/admin/recall-debug/panel Behavior: +- `POST /v2/context-packs` returns `elf.context_pack/v1`, an ephemeral read-time + scoped view over current readable recall layers. Context Packs include + `pack_id`, schema/version, title/description, activation policy, authority layers, + typed selectors, required anchors, read-profile policy, freshness policy, budget + limits, ranking policy, debug policy, `elf.context_pack.routing_trace/v1`, bounded + item references, and embedded `elf.recall_trace/v1`. Context Pack creation must + not store conclusions, mutate authority, write traces, write Qdrant points, or + become durable memory. +- Context Pack default routing is automatic. Manual enable/disable/pin controls are + override/debug/test/admin aids only. Pinning may affect eligible item priority but + cannot bypass scope, read_profile, grants, freshness, deletion, redaction, + authority, evidence, or required-anchor checks. +- Context Pack debug output must not leak unreadable/private source existence, counts, + refs, or content. Ineligible pinned, stale, deleted, expired, ungranted, or private + rows must remain omitted from pack items and represented only through public-safe + routing or recall-debug states. - The endpoints return `elf.recall_debug_panel/v1`, a read-only cross-layer panel over Memory Note trace bundles, Source Library document search, Knowledge Workspace page search, graph reports, and Dreaming review queue proposals. @@ -1200,6 +1217,7 @@ Behavior: pass claim. - Requested layer failures must be represented as blocked layer evidence, so one unavailable readback surface does not hide the other layer states. +- The detailed Context Pack contract is defined in `system_context_pack_v1.md`. - The detailed contract is defined in `system_recall_debug_panel_v1.md`. Admin derived knowledge pages: @@ -2595,6 +2613,7 @@ Original query: - elf_admin_trajectory_get -> GET /v2/admin/trajectories/{trace_id} - elf_admin_trace_item_get -> GET /v2/admin/trace-items/{item_id} - elf_admin_trace_bundle_get -> GET /v2/admin/traces/{trace_id}/bundle + - elf_context_pack_build -> POST /v2/context-packs - elf_recall_debug_panel -> POST /v2/recall-debug/panel - elf_admin_note_provenance_get -> GET /v2/admin/notes/{note_id}/provenance - elf_admin_memory_history_get -> GET /v2/admin/notes/{note_id}/history diff --git a/docs/spec/system_version_registry.md b/docs/spec/system_version_registry.md index 8b361533..c84ea0ef 100644 --- a/docs/spec/system_version_registry.md +++ b/docs/spec/system_version_registry.md @@ -197,6 +197,30 @@ This document is normative. When a new versioned identifier is introduced, it mu - Bump rule: Introduce a new identifier only if layer names, selection states, evidence-class semantics, replay fields, or required row keys become incompatible. +### Context Pack schema + +- Identifier: `elf.context_pack/v1`. +- Type: Ephemeral read-time context bundle over current readable recall layers. +- Defined in: `packages/elf-service/src/context_pack.rs` + (`ELF_CONTEXT_PACK_SCHEMA_V1`) and `docs/spec/system_context_pack_v1.md`. +- Consumers: `POST /v2/context-packs`, `apps/elf-api`, `apps/elf-mcp`, and + agent workflows that need bounded cited context without creating memory. +- Bump rule: Introduce a new identifier only if required pack fields, item + eligibility, authority-layer semantics, or privacy/debug fields become incompatible. + +### Context Pack routing trace schema + +- Identifier: `elf.context_pack.routing_trace/v1`. +- Type: Activation and selection trace embedded in `elf.context_pack/v1`. +- Defined in: `packages/elf-service/src/context_pack.rs` + (`ELF_CONTEXT_PACK_ROUTING_TRACE_SCHEMA_V1`) and + `docs/spec/system_context_pack_v1.md`. +- Consumers: `POST /v2/context-packs`, `apps/elf-api`, `apps/elf-mcp`, and + fixture assertions for routing, suppression, blocked, not-requested, and + pinned-ineligible states. +- Bump rule: Introduce a new identifier only if activation states, reason-code + semantics, pin behavior, or privacy fields become incompatible. + ### Recall trace schema - Identifier: `elf.recall_trace/v1`. diff --git a/packages/elf-service/src/context_pack.rs b/packages/elf-service/src/context_pack.rs new file mode 100644 index 00000000..7b037637 --- /dev/null +++ b/packages/elf-service/src/context_pack.rs @@ -0,0 +1,1239 @@ +//! Context Pack v1 read-time assembly over recall/debug readbacks. + +use std::collections::{BTreeMap, BTreeSet}; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use time::{Duration, OffsetDateTime}; +use uuid::Uuid; + +use crate::{ + ElfService, Error, GraphQueryEntityRef, GraphQueryPredicateRef, RecallDebugLayer, + RecallDebugPanelRequest, RecallDebugPanelResponse, RecallDebugRow, RecallTrace, Result, +}; +use elf_domain::english_gate; + +/// Context Pack v1 schema identifier. +pub const ELF_CONTEXT_PACK_SCHEMA_V1: &str = "elf.context_pack/v1"; +/// Context Pack routing trace schema identifier. +pub const ELF_CONTEXT_PACK_ROUTING_TRACE_SCHEMA_V1: &str = "elf.context_pack.routing_trace/v1"; + +const LAYER_MEMORY: &str = "memory_notes"; +const LAYER_DOCS: &str = "source_documents"; +const LAYER_KNOWLEDGE: &str = "knowledge_pages"; +const LAYER_GRAPH: &str = "graph_facts"; +const LAYER_DREAMING: &str = "dreaming_proposals"; +const PACK_TTL_MINUTES: i64 = 30; +const DEFAULT_CONTEXT_PACK_LIMIT: u32 = 12; +const MAX_CONTEXT_PACK_LIMIT: u32 = 50; + +/// Request payload for a read-time Context Pack. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct ContextPackRequest { + /// Tenant that owns the readback. + pub tenant_id: String, + /// Project that owns the readback. + pub project_id: String, + /// Agent requesting the readback. + pub agent_id: String, + /// Read profile used for every underlying recall surface. + pub read_profile: String, + /// Task description used by automatic routing. + pub task: String, + /// Optional caller-provided title. + pub title: Option, + /// Optional caller-provided description. + pub description: Option, + /// Optional search trace anchor for memory rows. + pub trace_id: Option, + /// Shared query used when docs_query or knowledge_query are omitted. + pub query: Option, + /// Optional Source Library query. + pub docs_query: Option, + /// Optional Knowledge Workspace page query. + pub knowledge_query: Option, + /// Optional graph subject selector. + pub graph_subject: Option, + /// Optional graph predicate selector. + pub graph_predicate: Option, + /// Whether to include Dreaming review queue proposals. + pub include_dreaming: Option, + /// Maximum pack items. + pub limit: Option, + /// Debug/test/admin-only routing overrides. + pub debug_overrides: Option, +} + +/// Debug/test/admin-only overrides for automatic routing. +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +pub struct ContextPackDebugOverrides { + /// Layers to force-enable when their required anchors are present. + #[serde(default)] + pub enable_layers: Vec, + /// Layers to suppress for this pack. + #[serde(default)] + pub disable_layers: Vec, + /// Layers to prioritize after normal eligibility checks. + #[serde(default)] + pub pin_layers: Vec, +} + +/// Read-time Context Pack response. +#[derive(Clone, Debug, Serialize)] +pub struct ContextPackResponse { + /// Response schema identifier. + pub schema: String, + /// Schema version. + pub version: u32, + /// Ephemeral pack identifier. + pub pack_id: Uuid, + #[serde(with = "crate::time_serde")] + /// Pack generation timestamp. + pub generated_at: OffsetDateTime, + #[serde(with = "crate::time_serde")] + /// Pack expiration timestamp. + pub expires_at: OffsetDateTime, + /// Pack title. + pub title: String, + /// Pack description. + pub description: String, + /// Automatic activation policy and override boundaries. + pub activation_policy: ContextPackActivationPolicy, + /// Authority layers considered by this pack. + pub authority_layers: Vec, + /// Typed selectors per layer. + pub typed_selectors: BTreeMap, + /// Required anchors and whether they were supplied. + pub required_anchors: Vec, + /// Read-profile policy applied by underlying recall surfaces. + pub read_profile_policy: ContextPackReadProfilePolicy, + /// Freshness policy applied to pack items. + pub freshness_policy: ContextPackFreshnessPolicy, + /// Budget limits for the pack. + pub budget_limits: ContextPackBudgetLimits, + /// Ranking policy for item ordering. + pub ranking_policy: ContextPackRankingPolicy, + /// Debug and privacy policy. + pub debug_policy: ContextPackDebugPolicy, + /// Activation and selection trace. + pub routing_trace: ContextPackRoutingTrace, + /// Bounded eligible context item references. + pub items: Vec, + /// Underlying recall trace after scope and freshness gates. + pub recall_trace: RecallTrace, +} + +/// Automatic activation policy metadata. +#[derive(Clone, Debug, Serialize)] +pub struct ContextPackActivationPolicy { + /// Policy schema identifier. + pub schema: String, + /// Routing mode. + pub mode: String, + /// Whether manual overrides were supplied. + pub manual_override_present: bool, + /// Override boundary. + pub override_policy: String, +} + +/// Authority layer metadata. +#[derive(Clone, Debug, Serialize)] +pub struct ContextPackAuthorityLayer { + /// Layer identifier. + pub layer: String, + /// Authority class for the layer. + pub authority_state: String, + /// Whether selected rows from this layer can become pack items. + pub eligible_for_pack_items: bool, +} + +/// Layer selector state. +#[derive(Clone, Debug, Serialize)] +pub struct ContextPackLayerSelector { + /// Layer identifier. + pub layer: String, + /// Selector state. + pub state: String, + /// Selector kind. + pub selector_type: String, + /// Selector payload. + pub selector: Value, + /// Reason code. + pub reason_code: String, + /// Whether this selector was affected by a manual override. + pub manual_override: bool, + /// Whether this layer was pinned for priority. + pub pinned: bool, +} + +/// Required anchor state. +#[derive(Clone, Debug, Serialize)] +pub struct ContextPackRequiredAnchor { + /// Layer identifier. + pub layer: String, + /// Required anchor name. + pub anchor: String, + /// Whether the anchor was supplied. + pub supplied: bool, + /// Reason code for missing or present state. + pub reason_code: String, +} + +/// Read-profile policy metadata. +#[derive(Clone, Debug, Serialize)] +pub struct ContextPackReadProfilePolicy { + /// Read profile used for all layers. + pub read_profile: String, + /// Privacy boundary. + pub boundary: String, + /// Scope behavior. + pub scope_policy: String, +} + +/// Freshness policy metadata. +#[derive(Clone, Debug, Serialize)] +pub struct ContextPackFreshnessPolicy { + /// Current-source requirement. + pub current_only: bool, + /// Suppressed freshness states. + pub suppressed_states: Vec, + /// Redaction behavior. + pub redaction_policy: String, +} + +/// Pack budget limits. +#[derive(Clone, Debug, Serialize)] +pub struct ContextPackBudgetLimits { + /// Maximum returned items. + pub max_items: u32, + /// Maximum rows requested per recall layer. + pub per_layer_recall_limit: u32, +} + +/// Pack ranking policy. +#[derive(Clone, Debug, Serialize)] +pub struct ContextPackRankingPolicy { + /// Ranking schema identifier. + pub schema: String, + /// Priority behavior. + pub priority_policy: String, + /// Whether pinning can bypass eligibility. + pub pin_bypasses_eligibility: bool, +} + +/// Pack debug policy. +#[derive(Clone, Debug, Serialize)] +pub struct ContextPackDebugPolicy { + /// Debug schema identifier. + pub schema: String, + /// Whether activation trace is included. + pub activation_trace: bool, + /// Source-ref privacy policy. + pub source_ref_policy: String, + /// Unreadable evidence policy. + pub unreadable_policy: String, +} + +/// Context Pack routing trace. +#[derive(Clone, Debug, Serialize)] +pub struct ContextPackRoutingTrace { + /// Trace schema identifier. + pub schema: String, + /// Trace entries. + pub entries: Vec, + /// Selected layer count. + pub selected_count: usize, + /// Suppressed layer count. + pub suppressed_count: usize, + /// Blocked layer count. + pub blocked_count: usize, + /// Not-requested layer count. + pub not_requested_count: usize, + /// Pinned layers that remained ineligible. + pub pinned_ineligible_count: usize, +} + +/// One routing trace entry. +#[derive(Clone, Debug, Serialize)] +pub struct ContextPackRoutingTraceEntry { + /// Layer identifier. + pub layer: String, + /// Activation state. + pub activation_state: String, + /// Reason code. + pub reason_code: String, + /// Human-readable policy reason. + pub policy_reason: String, + /// Whether a manual override affected the layer. + pub manual_override: bool, + /// Whether the layer was pinned. + pub pinned: bool, + /// Privacy note. + pub privacy: String, +} + +/// One item reference in the Context Pack. +#[derive(Clone, Debug, Serialize)] +pub struct ContextPackItem { + /// Source layer. + pub layer: String, + /// Authority layer. + pub authority_layer: String, + /// Freshness state. + pub freshness_state: String, + /// Item reference. + pub item_ref: Value, + /// Source refs for current readable evidence only. + pub source_refs: Value, + /// Score if available. + pub score: Option, + /// Rank if available. + pub rank: Option, + /// Selection or routing reason. + pub reason_code: String, + /// Whether pinning raised this item's priority. + pub pinned_priority: bool, +} + +#[derive(Clone, Debug)] +struct RoutedPack { + limit: u32, + title: String, + description: String, + docs_query: Option, + knowledge_query: Option, + include_dreaming: bool, + selectors: BTreeMap, + required_anchors: Vec, + disabled_layers: BTreeSet, + pinned_layers: BTreeSet, +} + +struct LayerRouteInput<'a> { + layer: &'a str, + default_enabled: bool, + manual_enabled: bool, + manual_disabled: bool, + pinned: bool, + anchor_name: &'a str, + anchor_supplied: bool, + selector_type: &'a str, + selector: Value, +} + +struct SelectorSources<'a> { + req: &'a ContextPackRequest, + docs_query: &'a Option, + knowledge_query: &'a Option, + include_dreaming: bool, + enabled: &'a BTreeSet, + disabled: &'a BTreeSet, + pinned: &'a BTreeSet, +} + +impl ElfService { + /// Builds a Context Pack as an ephemeral read-time view over current recall layers. + pub async fn context_pack_build(&self, req: ContextPackRequest) -> Result { + validate_context_pack_request(&req)?; + + let routed = route_context_pack(&req); + let recall = self + .recall_debug_panel(RecallDebugPanelRequest { + tenant_id: req.tenant_id.clone(), + project_id: req.project_id.clone(), + agent_id: req.agent_id.clone(), + read_profile: req.read_profile.clone(), + trace_id: selector_enabled(&routed, LAYER_MEMORY).then_some(req.trace_id).flatten(), + query: None, + docs_query: selector_enabled(&routed, LAYER_DOCS) + .then(|| routed.docs_query.clone()) + .flatten(), + knowledge_query: selector_enabled(&routed, LAYER_KNOWLEDGE) + .then(|| routed.knowledge_query.clone()) + .flatten(), + graph_subject: selector_enabled(&routed, LAYER_GRAPH) + .then(|| req.graph_subject.clone()) + .flatten(), + graph_predicate: selector_enabled(&routed, LAYER_GRAPH) + .then(|| req.graph_predicate.clone()) + .flatten(), + include_dreaming: Some( + selector_enabled(&routed, LAYER_DREAMING) && routed.include_dreaming, + ), + limit: Some(routed.limit), + allow_project_trace_debug: false, + }) + .await?; + + Ok(build_context_pack_response(&req, routed, recall)) + } +} + +fn validate_context_pack_request(req: &ContextPackRequest) -> Result<()> { + validate_required_english("task", req.task.as_str())?; + validate_optional_english("title", req.title.as_deref())?; + validate_optional_english("description", req.description.as_deref())?; + validate_optional_english("query", req.query.as_deref())?; + validate_optional_english("docs_query", req.docs_query.as_deref())?; + validate_optional_english("knowledge_query", req.knowledge_query.as_deref())?; + validate_graph_entity_ref("graph_subject", req.graph_subject.as_ref())?; + validate_graph_predicate_ref("graph_predicate", req.graph_predicate.as_ref())?; + + Ok(()) +} + +fn validate_required_english(field: &str, value: &str) -> Result<()> { + let trimmed = value.trim(); + + if trimmed.is_empty() { + return Err(Error::InvalidRequest { message: format!("{field} must be non-empty.") }); + } + if !english_gate::is_english_natural_language(trimmed) { + return Err(Error::NonEnglishInput { field: format!("$.{field}") }); + } + + Ok(()) +} + +fn validate_optional_english(field: &str, value: Option<&str>) -> Result<()> { + if let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) + && !english_gate::is_english_natural_language(value) + { + return Err(Error::NonEnglishInput { field: format!("$.{field}") }); + } + + Ok(()) +} + +fn validate_graph_entity_ref(field: &str, value: Option<&GraphQueryEntityRef>) -> Result<()> { + if let Some(GraphQueryEntityRef::Surface { surface }) = value { + validate_identifier_english(format!("$.{field}.surface"), surface.as_str())?; + } + + Ok(()) +} + +fn validate_graph_predicate_ref(field: &str, value: Option<&GraphQueryPredicateRef>) -> Result<()> { + if let Some(GraphQueryPredicateRef::Surface { surface }) = value { + validate_identifier_english(format!("$.{field}.surface"), surface.as_str())?; + } + + Ok(()) +} + +fn validate_identifier_english(field: String, value: &str) -> Result<()> { + if !english_gate::is_english_identifier(value.trim()) { + return Err(Error::NonEnglishInput { field }); + } + + Ok(()) +} + +fn route_context_pack(req: &ContextPackRequest) -> RoutedPack { + let limit = req.limit.unwrap_or(DEFAULT_CONTEXT_PACK_LIMIT).clamp(1, MAX_CONTEXT_PACK_LIMIT); + let task = trimmed_opt(Some(req.task.as_str())); + let base_query = trimmed_opt(req.query.as_deref()).or_else(|| task.clone()); + let docs_query = trimmed_opt(req.docs_query.as_deref()).or_else(|| base_query.clone()); + let knowledge_query = + trimmed_opt(req.knowledge_query.as_deref()).or_else(|| base_query.clone()); + let include_dreaming = req.include_dreaming == Some(true) + || task.as_deref().is_some_and(|value| { + contains_any(value, &["proposal", "review", "dreaming", "consolidation"]) + }); + let overrides = req.debug_overrides.clone().unwrap_or_default(); + let enabled = normalize_layers(overrides.enable_layers); + let disabled = normalize_layers(overrides.disable_layers); + let pinned = normalize_layers(overrides.pin_layers); + let title = trimmed_opt(req.title.as_deref()).unwrap_or_else(|| "Context Pack".to_string()); + let description = trimmed_opt(req.description.as_deref()).unwrap_or_else(|| { + "Read-time scoped context assembled from current ELF authority layers.".to_string() + }); + let (selectors, required_anchors) = route_selectors(SelectorSources { + req, + docs_query: &docs_query, + knowledge_query: &knowledge_query, + include_dreaming, + enabled: &enabled, + disabled: &disabled, + pinned: &pinned, + }); + + RoutedPack { + limit, + title, + description, + docs_query, + knowledge_query, + include_dreaming: include_dreaming || enabled.contains(LAYER_DREAMING), + selectors, + required_anchors, + disabled_layers: disabled, + pinned_layers: pinned, + } +} + +fn route_selectors( + input: SelectorSources<'_>, +) -> (BTreeMap, Vec) { + let mut selectors = BTreeMap::new(); + let mut required_anchors = Vec::new(); + + add_selector( + &mut selectors, + &mut required_anchors, + LayerRouteInput { + layer: LAYER_MEMORY, + default_enabled: input.req.trace_id.is_some(), + manual_enabled: input.enabled.contains(LAYER_MEMORY), + manual_disabled: input.disabled.contains(LAYER_MEMORY), + pinned: input.pinned.contains(LAYER_MEMORY), + anchor_name: "trace_id", + anchor_supplied: input.req.trace_id.is_some(), + selector_type: "trace", + selector: input + .req + .trace_id + .map(|trace_id| serde_json::json!({ "trace_id": trace_id })) + .unwrap_or_else(|| serde_json::json!({})), + }, + ); + add_selector( + &mut selectors, + &mut required_anchors, + LayerRouteInput { + layer: LAYER_DOCS, + default_enabled: input.docs_query.is_some(), + manual_enabled: input.enabled.contains(LAYER_DOCS), + manual_disabled: input.disabled.contains(LAYER_DOCS), + pinned: input.pinned.contains(LAYER_DOCS), + anchor_name: "docs_query", + anchor_supplied: input.docs_query.is_some(), + selector_type: "query", + selector: input + .docs_query + .as_ref() + .map(|query| serde_json::json!({ "query": query })) + .unwrap_or_else(|| serde_json::json!({})), + }, + ); + add_selector( + &mut selectors, + &mut required_anchors, + LayerRouteInput { + layer: LAYER_KNOWLEDGE, + default_enabled: input.knowledge_query.is_some(), + manual_enabled: input.enabled.contains(LAYER_KNOWLEDGE), + manual_disabled: input.disabled.contains(LAYER_KNOWLEDGE), + pinned: input.pinned.contains(LAYER_KNOWLEDGE), + anchor_name: "knowledge_query", + anchor_supplied: input.knowledge_query.is_some(), + selector_type: "query", + selector: input + .knowledge_query + .as_ref() + .map(|query| serde_json::json!({ "query": query })) + .unwrap_or_else(|| serde_json::json!({})), + }, + ); + add_selector( + &mut selectors, + &mut required_anchors, + LayerRouteInput { + layer: LAYER_GRAPH, + default_enabled: input.req.graph_subject.is_some(), + manual_enabled: input.enabled.contains(LAYER_GRAPH), + manual_disabled: input.disabled.contains(LAYER_GRAPH), + pinned: input.pinned.contains(LAYER_GRAPH), + anchor_name: "graph_subject", + anchor_supplied: input.req.graph_subject.is_some(), + selector_type: "graph_subject", + selector: input + .req + .graph_subject + .as_ref() + .map(|subject| serde_json::json!({ "subject": subject })) + .unwrap_or_else(|| serde_json::json!({})), + }, + ); + add_selector( + &mut selectors, + &mut required_anchors, + LayerRouteInput { + layer: LAYER_DREAMING, + default_enabled: input.include_dreaming, + manual_enabled: input.enabled.contains(LAYER_DREAMING), + manual_disabled: input.disabled.contains(LAYER_DREAMING), + pinned: input.pinned.contains(LAYER_DREAMING), + anchor_name: "include_dreaming", + anchor_supplied: input.include_dreaming || input.enabled.contains(LAYER_DREAMING), + selector_type: "review_queue", + selector: serde_json::json!({ + "include_dreaming": input.include_dreaming || input.enabled.contains(LAYER_DREAMING) + }), + }, + ); + + (selectors, required_anchors) +} + +fn add_selector( + selectors: &mut BTreeMap, + required_anchors: &mut Vec, + input: LayerRouteInput<'_>, +) { + let manual_override = input.manual_enabled || input.manual_disabled || input.pinned; + let (state, reason_code) = if input.manual_disabled { + ("suppressed", "MANUAL_DISABLED") + } else if !input.anchor_supplied && (input.manual_enabled || input.pinned) { + ("suppressed", "PINNED_OR_ENABLED_MISSING_REQUIRED_ANCHOR") + } else if input.default_enabled || input.manual_enabled { + ("selected", "AUTOMATIC_ROUTING_MATCH") + } else { + ("not_requested", "AUTOMATIC_ROUTING_NO_MATCH") + }; + + required_anchors.push(ContextPackRequiredAnchor { + layer: input.layer.to_string(), + anchor: input.anchor_name.to_string(), + supplied: input.anchor_supplied, + reason_code: if input.anchor_supplied { "ANCHOR_SUPPLIED" } else { "ANCHOR_MISSING" } + .to_string(), + }); + selectors.insert( + input.layer.to_string(), + ContextPackLayerSelector { + layer: input.layer.to_string(), + state: state.to_string(), + selector_type: input.selector_type.to_string(), + selector: input.selector, + reason_code: reason_code.to_string(), + manual_override, + pinned: input.pinned, + }, + ); +} + +fn build_context_pack_response( + req: &ContextPackRequest, + routed: RoutedPack, + recall: RecallDebugPanelResponse, +) -> ContextPackResponse { + let generated_at = OffsetDateTime::now_utc(); + let mut items = pack_items(&recall.layers, &routed); + + items.truncate(routed.limit as usize); + + let routing_trace = build_routing_trace(&routed, &recall.layers); + + ContextPackResponse { + schema: ELF_CONTEXT_PACK_SCHEMA_V1.to_string(), + version: 1, + pack_id: Uuid::new_v4(), + generated_at, + expires_at: generated_at + Duration::minutes(PACK_TTL_MINUTES), + title: routed.title.clone(), + description: routed.description.clone(), + activation_policy: ContextPackActivationPolicy { + schema: "elf.context_pack.activation_policy/v1".to_string(), + mode: "automatic".to_string(), + manual_override_present: req.debug_overrides.is_some(), + override_policy: + "manual enable/disable/pin is debug/test/admin-only; pinning changes priority only" + .to_string(), + }, + authority_layers: authority_layers(), + typed_selectors: routed.selectors.clone(), + required_anchors: routed.required_anchors.clone(), + read_profile_policy: ContextPackReadProfilePolicy { + read_profile: req.read_profile.clone(), + boundary: "all layer reads use the request read_profile and service grants".to_string(), + scope_policy: + "agent_private requires owner match; shared scopes require readable grants" + .to_string(), + }, + freshness_policy: ContextPackFreshnessPolicy { + current_only: true, + suppressed_states: vec![ + "deleted".to_string(), + "deprecated".to_string(), + "expired".to_string(), + "stale".to_string(), + "superseded".to_string(), + "tombstoned".to_string(), + ], + redaction_policy: + "items include only source refs already returned by readable current recall rows" + .to_string(), + }, + budget_limits: ContextPackBudgetLimits { + max_items: routed.limit, + per_layer_recall_limit: routed.limit, + }, + ranking_policy: ContextPackRankingPolicy { + schema: "elf.context_pack.ranking_policy/v1".to_string(), + priority_policy: + "eligible pinned layers sort ahead of unpinned layers, then by rank and score" + .to_string(), + pin_bypasses_eligibility: false, + }, + debug_policy: ContextPackDebugPolicy { + schema: "elf.context_pack.debug_policy/v1".to_string(), + activation_trace: true, + source_ref_policy: + "debug output omits unreadable/private source existence, counts, refs, and content" + .to_string(), + unreadable_policy: + "unreadable rows are filtered by underlying recall surfaces before pack assembly" + .to_string(), + }, + routing_trace, + items, + recall_trace: recall.recall_trace, + } +} + +fn authority_layers() -> Vec { + [ + (LAYER_MEMORY, "authoritative_memory", true), + (LAYER_DOCS, "source_evidence", true), + (LAYER_KNOWLEDGE, "derived_knowledge", true), + (LAYER_GRAPH, "graph_lite_fact", true), + (LAYER_DREAMING, "reviewable_proposal", true), + ] + .into_iter() + .map(|(layer, authority_state, eligible_for_pack_items)| ContextPackAuthorityLayer { + layer: layer.to_string(), + authority_state: authority_state.to_string(), + eligible_for_pack_items, + }) + .collect() +} + +fn build_routing_trace( + routed: &RoutedPack, + layers: &[RecallDebugLayer], +) -> ContextPackRoutingTrace { + let layer_map = + layers.iter().map(|layer| (layer.layer.as_str(), layer)).collect::>(); + let mut entries = Vec::new(); + + for (layer, selector) in &routed.selectors { + let mut activation_state = selector.state.clone(); + let mut reason_code = selector.reason_code.clone(); + let mut policy_reason = match selector.reason_code.as_str() { + "MANUAL_DISABLED" => "Layer was suppressed by a debug override.".to_string(), + "PINNED_OR_ENABLED_MISSING_REQUIRED_ANCHOR" => + "Pinned or enabled layer stayed ineligible because a required anchor was missing." + .to_string(), + "AUTOMATIC_ROUTING_MATCH" => "Automatic routing selected this layer.".to_string(), + _ => "Automatic routing did not request this layer.".to_string(), + }; + + if let Some(recall_layer) = layer_map.get(layer.as_str()) { + if recall_layer.evidence_class == "blocked" { + activation_state = "blocked".to_string(); + reason_code = "RECALL_LAYER_BLOCKED".to_string(); + policy_reason = recall_layer.summary.clone(); + } else if selector.state == "selected" + && recall_layer.rows.iter().all(|row| !row_eligible_for_pack(row)) + { + activation_state = if selector.pinned { + "pinned_ineligible".to_string() + } else { + "suppressed".to_string() + }; + reason_code = "NO_CURRENT_READABLE_SELECTED_ROWS".to_string(); + policy_reason = + "Layer returned no current readable selected, available, or reviewable rows." + .to_string(); + } + } + + entries.push(ContextPackRoutingTraceEntry { + layer: layer.clone(), + activation_state, + reason_code, + policy_reason, + manual_override: selector.manual_override, + pinned: selector.pinned, + privacy: "no unreadable source existence, counts, refs, or content disclosed" + .to_string(), + }); + } + + ContextPackRoutingTrace { + schema: ELF_CONTEXT_PACK_ROUTING_TRACE_SCHEMA_V1.to_string(), + selected_count: entries.iter().filter(|entry| entry.activation_state == "selected").count(), + suppressed_count: entries + .iter() + .filter(|entry| entry.activation_state == "suppressed") + .count(), + blocked_count: entries.iter().filter(|entry| entry.activation_state == "blocked").count(), + not_requested_count: entries + .iter() + .filter(|entry| entry.activation_state == "not_requested") + .count(), + pinned_ineligible_count: entries + .iter() + .filter(|entry| entry.activation_state == "pinned_ineligible") + .count(), + entries, + } +} + +fn pack_items(layers: &[RecallDebugLayer], routed: &RoutedPack) -> Vec { + let mut items = layers + .iter() + .filter(|layer| !routed.disabled_layers.contains(layer.layer.as_str())) + .flat_map(|layer| { + layer.rows.iter().filter(|row| row_eligible_for_pack(row)).map(|row| { + let pinned_priority = routed.pinned_layers.contains(row.layer.as_str()); + + ContextPackItem { + layer: row.layer.clone(), + authority_layer: row.authority_layer.clone(), + freshness_state: row.freshness_state.clone(), + item_ref: row.item_ref.clone(), + source_refs: row.source_refs.clone(), + score: row.score, + rank: row.rank, + reason_code: row + .stage_reason + .clone() + .or_else(|| row.rationale.clone()) + .unwrap_or_else(|| "selected_by_recall_layer".to_string()), + pinned_priority, + } + }) + }) + .collect::>(); + + items.sort_by(|left, right| { + right + .pinned_priority + .cmp(&left.pinned_priority) + .then_with(|| left.rank.unwrap_or(u32::MAX).cmp(&right.rank.unwrap_or(u32::MAX))) + .then_with(|| { + right + .score + .unwrap_or(f32::NEG_INFINITY) + .total_cmp(&left.score.unwrap_or(f32::NEG_INFINITY)) + }) + .then_with(|| left.layer.cmp(&right.layer)) + }); + + items +} + +fn row_eligible_for_pack(row: &RecallDebugRow) -> bool { + matches!(row.selection_state.as_str(), "selected" | "available" | "reviewable") + && row.evidence_class == "pass" + && layer_freshness_eligible(row.layer.as_str(), row.freshness_state.as_str()) + && source_refs_present(&row.source_refs) +} + +fn layer_freshness_eligible(layer: &str, freshness_state: &str) -> bool { + if layer == LAYER_DREAMING { + return matches!(freshness_state, "proposed" | "approved"); + } + + !stale_or_non_current(freshness_state) +} + +fn stale_or_non_current(freshness_state: &str) -> bool { + matches!( + freshness_state, + "deleted" + | "deprecated" + | "expired" + | "stale" | "superseded" + | "tombstoned" + | "historical" + | "future" + ) +} + +fn source_refs_present(value: &Value) -> bool { + match value { + Value::Null => false, + Value::Array(values) => !values.is_empty(), + Value::Object(values) => + ["source_refs", "source_ref", "source_snapshot", "affected_refs", "evidence_note_ids"] + .iter() + .filter_map(|key| values.get(*key)) + .any(source_refs_present), + Value::String(value) => !value.trim().is_empty(), + _ => true, + } +} + +fn selector_enabled(routed: &RoutedPack, layer: &str) -> bool { + routed.selectors.get(layer).is_some_and(|selector| selector.state == "selected") +} + +fn trimmed_opt(value: Option<&str>) -> Option { + value.map(str::trim).filter(|value| !value.is_empty()).map(str::to_string) +} + +fn contains_any(value: &str, needles: &[&str]) -> bool { + let lower = value.to_ascii_lowercase(); + + needles.iter().any(|needle| lower.contains(needle)) +} + +fn normalize_layers(layers: Vec) -> BTreeSet { + layers + .into_iter() + .filter_map(|layer| { + let normalized = layer.trim().to_ascii_lowercase().replace('-', "_"); + + match normalized.as_str() { + LAYER_MEMORY | "memory" | "notes" => Some(LAYER_MEMORY.to_string()), + LAYER_DOCS | "docs" | "documents" | "source" => Some(LAYER_DOCS.to_string()), + LAYER_KNOWLEDGE | "knowledge" | "pages" => Some(LAYER_KNOWLEDGE.to_string()), + LAYER_GRAPH | "graph" | "relations" => Some(LAYER_GRAPH.to_string()), + LAYER_DREAMING | "dreaming" | "proposals" => Some(LAYER_DREAMING.to_string()), + _ => None, + } + }) + .collect() +} + +#[cfg(test)] +mod tests { + use serde_json::Value; + + use crate::{ + Error, GraphQueryEntityRef, RecallDebugLayer, RecallDebugRow, + context_pack::{ + self, ContextPackDebugOverrides, ContextPackRequest, LAYER_DOCS, LAYER_DREAMING, + LAYER_GRAPH, LAYER_KNOWLEDGE, LAYER_MEMORY, + }, + }; + + fn base_request() -> ContextPackRequest { + ContextPackRequest { + tenant_id: "tenant".to_string(), + project_id: "project".to_string(), + agent_id: "agent".to_string(), + read_profile: "private_plus_project".to_string(), + task: "Find current source-backed decisions for routing work.".to_string(), + title: None, + description: None, + trace_id: None, + query: None, + docs_query: None, + knowledge_query: None, + graph_subject: None, + graph_predicate: None, + include_dreaming: None, + limit: Some(5), + debug_overrides: None, + } + } + + fn row( + layer: &str, + selection_state: &str, + freshness_state: &str, + source_refs: Value, + ) -> RecallDebugRow { + RecallDebugRow { + layer: layer.to_string(), + item_ref: serde_json::json!({"id": layer}), + selection_state: selection_state.to_string(), + authority_layer: layer.to_string(), + freshness_state: freshness_state.to_string(), + source_refs, + score: Some(0.7), + rank: Some(1), + rationale: Some("test row".to_string()), + stage_reason: Some("test_stage".to_string()), + replay_command: None, + evidence_class: "pass".to_string(), + debug_artifacts: serde_json::json!({}), + } + } + + #[test] + fn automatic_routing_activates_query_layers_and_schema_fields() { + let routed = context_pack::route_context_pack(&base_request()); + + assert_eq!(routed.selectors[LAYER_DOCS].state, "selected"); + assert_eq!(routed.selectors[LAYER_KNOWLEDGE].state, "selected"); + assert_eq!(routed.selectors[LAYER_MEMORY].state, "not_requested"); + assert_eq!(routed.limit, 5); + assert!(routed.required_anchors.iter().any(|anchor| { + anchor.layer == LAYER_DOCS && anchor.anchor == "docs_query" && anchor.supplied + })); + } + + #[test] + fn manual_disable_suppresses_layer_without_removing_other_automatic_routes() { + let mut req = base_request(); + + req.debug_overrides = Some(ContextPackDebugOverrides { + disable_layers: vec![LAYER_DOCS.to_string()], + ..ContextPackDebugOverrides::default() + }); + + let routed = context_pack::route_context_pack(&req); + + assert_eq!(routed.selectors[LAYER_DOCS].state, "suppressed"); + assert_eq!(routed.selectors[LAYER_DOCS].reason_code, "MANUAL_DISABLED"); + assert_eq!(routed.selectors[LAYER_KNOWLEDGE].state, "selected"); + } + + #[test] + fn pinned_missing_anchor_is_ineligible_and_cannot_bypass_requirements() { + let mut req = base_request(); + + req.debug_overrides = Some(ContextPackDebugOverrides { + pin_layers: vec![LAYER_MEMORY.to_string()], + ..ContextPackDebugOverrides::default() + }); + + let routed = context_pack::route_context_pack(&req); + + assert_eq!(routed.selectors[LAYER_MEMORY].state, "suppressed"); + assert_eq!( + routed.selectors[LAYER_MEMORY].reason_code, + "PINNED_OR_ENABLED_MISSING_REQUIRED_ANCHOR" + ); + assert!(routed.selectors[LAYER_MEMORY].pinned); + } + + #[test] + fn pack_items_filter_unreadable_empty_source_refs_and_stale_or_deleted_rows() { + let rows = vec![ + row(LAYER_DOCS, "selected", "active", serde_json::json!([{"schema": "source_ref/v1"}])), + row( + LAYER_DOCS, + "selected", + "deleted", + serde_json::json!([{"schema": "source_ref/v1"}]), + ), + row( + LAYER_DOCS, + "selected", + "deprecated", + serde_json::json!([{"schema": "source_ref/v1"}]), + ), + row(LAYER_DOCS, "selected", "active", serde_json::json!([])), + row(LAYER_KNOWLEDGE, "selected", "active", serde_json::json!({"source_refs": []})), + row(LAYER_DOCS, "dropped", "active", serde_json::json!([{"schema": "source_ref/v1"}])), + ]; + let layer = RecallDebugLayer { + layer: LAYER_DOCS.to_string(), + evidence_class: "pass".to_string(), + summary: "docs".to_string(), + anchor: Some("query".to_string()), + row_count: rows.len(), + selected_count: 4, + dropped_count: 1, + available_count: 0, + raw_sql_needed: false, + replayable: false, + debug_artifacts: serde_json::json!({}), + rows, + }; + let routed = context_pack::route_context_pack(&base_request()); + let items = context_pack::pack_items(&[layer], &routed); + + assert_eq!(items.len(), 1); + assert_eq!(items[0].freshness_state, "active"); + } + + #[test] + fn graph_pack_items_accept_evidence_note_ids_and_suppress_non_current_facts() { + let rows = vec![ + row( + LAYER_GRAPH, + "available", + "current", + serde_json::json!({"evidence_note_ids": ["note-current"]}), + ), + row( + LAYER_GRAPH, + "available", + "historical", + serde_json::json!({"evidence_note_ids": ["note-historical"]}), + ), + row( + LAYER_GRAPH, + "available", + "future", + serde_json::json!({"evidence_note_ids": ["note-future"]}), + ), + row(LAYER_GRAPH, "available", "current", serde_json::json!({"evidence_note_ids": []})), + ]; + let layer = RecallDebugLayer { + layer: LAYER_GRAPH.to_string(), + evidence_class: "pass".to_string(), + summary: "graph".to_string(), + anchor: Some("entity".to_string()), + row_count: rows.len(), + selected_count: 0, + dropped_count: 0, + available_count: rows.len(), + raw_sql_needed: false, + replayable: false, + debug_artifacts: serde_json::json!({}), + rows, + }; + let routed = context_pack::route_context_pack(&base_request()); + let items = context_pack::pack_items(&[layer], &routed); + + assert_eq!(items.len(), 1); + assert_eq!(items[0].layer, LAYER_GRAPH); + assert_eq!(items[0].freshness_state, "current"); + } + + #[test] + fn dreaming_pack_items_only_include_active_review_states() { + let rows = vec![ + row( + LAYER_DREAMING, + "reviewable", + "proposed", + serde_json::json!({"source_refs": [{"schema": "source_ref/v1"}]}), + ), + row( + LAYER_DREAMING, + "reviewable", + "approved", + serde_json::json!({"source_refs": [{"schema": "source_ref/v1"}]}), + ), + row( + LAYER_DREAMING, + "reviewable", + "rejected", + serde_json::json!({"source_refs": [{"schema": "source_ref/v1"}]}), + ), + row( + LAYER_DREAMING, + "reviewable", + "applied", + serde_json::json!({"source_refs": [{"schema": "source_ref/v1"}]}), + ), + row( + LAYER_DREAMING, + "reviewable", + "archived", + serde_json::json!({"source_refs": [{"schema": "source_ref/v1"}]}), + ), + ]; + let layer = RecallDebugLayer { + layer: LAYER_DREAMING.to_string(), + evidence_class: "pass".to_string(), + summary: "dreaming".to_string(), + anchor: None, + row_count: rows.len(), + selected_count: 0, + dropped_count: 0, + available_count: 0, + raw_sql_needed: false, + replayable: false, + debug_artifacts: serde_json::json!({}), + rows, + }; + let routed = context_pack::route_context_pack(&base_request()); + let items = context_pack::pack_items(&[layer], &routed); + + assert_eq!(items.len(), 2); + assert!(items.iter().any(|item| item.freshness_state == "proposed")); + assert!(items.iter().any(|item| item.freshness_state == "approved")); + } + + #[test] + fn validation_rejects_empty_or_non_english_task_and_query() { + let mut req = base_request(); + + req.task = " ".to_string(); + + assert!(matches!( + context_pack::validate_context_pack_request(&req), + Err(Error::InvalidRequest { .. }) + )); + + req.task = "Find current source-backed decisions.".to_string(); + req.query = Some("決定".to_string()); + + assert!(matches!( + context_pack::validate_context_pack_request(&req), + Err(Error::NonEnglishInput { field }) if field == "$.query" + )); + + req.query = None; + req.title = Some("決定".to_string()); + + assert!(matches!( + context_pack::validate_context_pack_request(&req), + Err(Error::NonEnglishInput { field }) if field == "$.title" + )); + + req.title = None; + req.graph_subject = Some(GraphQueryEntityRef::Surface { surface: "決定".to_string() }); + + assert!(matches!( + context_pack::validate_context_pack_request(&req), + Err(Error::NonEnglishInput { field }) if field == "$.graph_subject.surface" + )); + } + + #[test] + fn disabled_layer_rows_are_suppressed_even_when_readable() { + let mut req = base_request(); + + req.debug_overrides = Some(ContextPackDebugOverrides { + disable_layers: vec![LAYER_DOCS.to_string()], + ..ContextPackDebugOverrides::default() + }); + + let routed = context_pack::route_context_pack(&req); + let layer = RecallDebugLayer { + layer: LAYER_DOCS.to_string(), + evidence_class: "pass".to_string(), + summary: "docs".to_string(), + anchor: Some("query".to_string()), + row_count: 1, + selected_count: 1, + dropped_count: 0, + available_count: 0, + raw_sql_needed: false, + replayable: false, + debug_artifacts: serde_json::json!({}), + rows: vec![row( + LAYER_DOCS, + "selected", + "active", + serde_json::json!([{"schema": "source_ref/v1"}]), + )], + }; + + assert!(context_pack::pack_items(&[layer], &routed).is_empty()); + } + + #[test] + fn routing_trace_does_not_disclose_suppressed_layer_counts_or_refs() { + let mut req = base_request(); + + req.debug_overrides = Some(ContextPackDebugOverrides { + pin_layers: vec![LAYER_MEMORY.to_string()], + ..ContextPackDebugOverrides::default() + }); + + let routed = context_pack::route_context_pack(&req); + let trace = context_pack::build_routing_trace(&routed, &[]); + let memory = trace + .entries + .iter() + .find(|entry| entry.layer == LAYER_MEMORY) + .expect("memory trace entry"); + + assert_eq!(memory.activation_state, "suppressed"); + assert_eq!(memory.reason_code, "PINNED_OR_ENABLED_MISSING_REQUIRED_ANCHOR"); + assert!(memory.privacy.contains("no unreadable source existence")); + assert!(!serde_json::to_string(memory).unwrap().contains("source_refs")); + } +} diff --git a/packages/elf-service/src/lib.rs b/packages/elf-service/src/lib.rs index 7c83da05..6b6b62b2 100644 --- a/packages/elf-service/src/lib.rs +++ b/packages/elf-service/src/lib.rs @@ -7,6 +7,7 @@ pub mod add_note; pub mod admin; pub mod admin_graph_predicates; pub mod consolidation; +pub mod context_pack; pub mod core_blocks; pub mod delete; pub mod docs; @@ -62,6 +63,14 @@ pub use self::{ ConsolidationRunResponse, ConsolidationRunsListRequest, ConsolidationRunsListResponse, }, constants::{REJECT_EVIDENCE_MISMATCH, REJECT_WRITE_POLICY_MISMATCH}, + context_pack::{ + ContextPackActivationPolicy, ContextPackAuthorityLayer, ContextPackBudgetLimits, + ContextPackDebugOverrides, ContextPackDebugPolicy, ContextPackFreshnessPolicy, + ContextPackItem, ContextPackLayerSelector, ContextPackRankingPolicy, + ContextPackReadProfilePolicy, ContextPackRequest, ContextPackRequiredAnchor, + ContextPackResponse, ContextPackRoutingTrace, ContextPackRoutingTraceEntry, + ELF_CONTEXT_PACK_ROUTING_TRACE_SCHEMA_V1, ELF_CONTEXT_PACK_SCHEMA_V1, + }, core_blocks::{ CoreBlockAttachRequest, CoreBlockAttachResponse, CoreBlockDetachRequest, CoreBlockDetachResponse, CoreBlockItem, CoreBlockRecord, CoreBlockUpsertRequest,