From edb40947d1d5a0c4d23b987dd0097ff7fdf8cee2 Mon Sep 17 00:00:00 2001 From: Ragnor Comerford Date: Sat, 30 May 2026 15:28:46 +0200 Subject: [PATCH] MR-969: add stored-query registry config surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce the `queries:` block in omnigraph.yaml — an inline `name -> entry` map of stored queries, per-graph (`graphs..queries`) and top-level for single-graph mode, mirroring how `policy` is wired in both modes. Each entry points at a `.gq` file and carries optional MCP exposure settings (`expose`, `tool_name`), defaulting to not-exposed. Additive: absent `queries:` leaves current behavior unchanged. - QueryEntry { file, mcp: McpSettings { expose, tool_name } } - `queries` field on TargetConfig + OmnigraphConfig (serde default) - query_entries() / target_query_entries() accessors - resolve_query_file() — base_dir-relative `.gq` path resolution - round-trip + absent-block tests Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/omnigraph-server/src/config.rs | 132 ++++++++++++++++++++++++++ 1 file changed, 132 insertions(+) diff --git a/crates/omnigraph-server/src/config.rs b/crates/omnigraph-server/src/config.rs index 87737d0..6833a26 100644 --- a/crates/omnigraph-server/src/config.rs +++ b/crates/omnigraph-server/src/config.rs @@ -24,6 +24,14 @@ pub struct TargetConfig { /// graph's HTTP-layer Cedar enforcement. #[serde(default)] pub policy: PolicySettings, + /// Per-graph stored-query registry: an inline `name -> entry` + /// map. Mirrors the per-graph `policy` shape — each + /// `graphs..queries` declares that graph's stored queries. Absent + /// (or empty) = no stored queries for the graph. v1 is inline-only; + /// an external `queries.yaml` manifest indirection is a deferred + /// convenience. + #[serde(default)] + pub queries: BTreeMap, } #[derive(Debug, Clone, Copy, Default, Eq, PartialEq, Serialize, Deserialize, ValueEnum)] @@ -90,6 +98,33 @@ pub struct PolicySettings { pub file: Option, } +/// One stored-query registry entry. The map **key** is the query's +/// identity — it must equal the `query ` symbol declared inside +/// the referenced `.gq` file (asserted at load, C2). Renaming the key +/// (or the symbol) is a breaking change to callers, by design. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct QueryEntry { + /// Path to the `.gq` file (relative to the config's `base_dir`). The + /// file may declare several queries; the registry selects the one + /// whose symbol matches the map key. + pub file: String, + #[serde(default)] + pub mcp: McpSettings, +} + +/// MCP exposure for a stored query. A *deployment* concern (the same +/// `.gq` may be exposed in one graph and hidden in another), so it lives +/// in YAML rather than in the `.gq` source. Default `expose: false` — +/// a query is HTTP-callable but absent from the MCP tool catalog unless +/// the operator opts in. The catalog projection lands in a later slice; +/// v1 round-trips these fields. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct McpSettings { + #[serde(default)] + pub expose: bool, + pub tool_name: Option, +} + #[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum AliasCommand { @@ -137,6 +172,12 @@ pub struct OmnigraphConfig { pub aliases: BTreeMap, #[serde(default)] pub policy: PolicySettings, + /// Top-level stored-query registry, used in single-graph + /// mode — mirrors how the top-level `policy` applies to the single + /// graph. In multi-graph mode this is unused; each graph's + /// `graphs..queries` applies instead. + #[serde(default)] + pub queries: BTreeMap, #[serde(skip)] base_dir: PathBuf, } @@ -152,6 +193,7 @@ impl Default for OmnigraphConfig { query: QueryDefaults::default(), aliases: BTreeMap::new(), policy: PolicySettings::default(), + queries: BTreeMap::new(), base_dir: PathBuf::new(), } } @@ -244,6 +286,28 @@ impl OmnigraphConfig { .map(|path| self.resolve_config_path(path)) } + /// The top-level stored-query registry entries (single-graph mode). + pub fn query_entries(&self) -> &BTreeMap { + &self.queries + } + + /// The per-graph stored-query registry entries for a named target + /// (multi-graph mode). Returns `None` if the target is unknown. + pub fn target_query_entries( + &self, + target_name: &str, + ) -> Option<&BTreeMap> { + self.graphs.get(target_name).map(|target| &target.queries) + } + + /// Resolve a stored-query `.gq` file path (from a registry entry), + /// relative to the config's `base_dir`. Mirrors policy-file + /// resolution; the registry loader (C2) calls this to turn each + /// entry's `file:` value into an absolute path. + pub fn resolve_query_file(&self, value: &str) -> PathBuf { + self.resolve_config_path(value) + } + /// Resolve the server-level policy file path (used by management /// endpoints). Returns `None` if `server.policy.file` is not set. pub fn resolve_server_policy_file(&self) -> Option { @@ -489,6 +553,74 @@ policy: {} assert_eq!(resolved, config_dir.join("local.gq")); } + #[test] + fn queries_block_round_trips_inline_and_per_graph() { + let temp = tempdir().unwrap(); + fs::write( + temp.path().join("omnigraph.yaml"), + r#" +graphs: + prod: + uri: s3://bucket/prod + queries: + find_user: + file: ./queries/find_user.gq + mcp: + expose: true + tool_name: lookup_user + internal_audit: + file: ./queries/audit.gq +queries: + single_mode_q: + file: ./q.gq +"#, + ) + .unwrap(); + + let config = load_config_in(temp.path(), None).unwrap(); + + // Per-graph registry (multi-graph mode). + let prod = config.target_query_entries("prod").unwrap(); + assert_eq!(prod.len(), 2); + let find_user = &prod["find_user"]; + assert_eq!(find_user.file, "./queries/find_user.gq"); + assert!(find_user.mcp.expose); + assert_eq!(find_user.mcp.tool_name.as_deref(), Some("lookup_user")); + // Default exposure is false (safe by default) and tool_name absent. + let audit = &prod["internal_audit"]; + assert!(!audit.mcp.expose); + assert!(audit.mcp.tool_name.is_none()); + + // Top-level registry (single-graph mode). + assert_eq!(config.query_entries().len(), 1); + + // Path resolution joins against base_dir, like policy files. + assert_eq!( + config.resolve_query_file(&find_user.file), + temp.path().join("./queries/find_user.gq") + ); + } + + #[test] + fn queries_block_absent_yields_empty_registry() { + let temp = tempdir().unwrap(); + fs::write( + temp.path().join("omnigraph.yaml"), + "graphs:\n local:\n uri: ./demo.omni\n", + ) + .unwrap(); + + let config = load_config_in(temp.path(), None).unwrap(); + // Additive: no `queries:` anywhere → empty registries everywhere. + assert!(config.query_entries().is_empty()); + assert!( + config + .target_query_entries("local") + .unwrap() + .is_empty() + ); + } + #[test] fn policy_block_accepts_non_empty_mapping() { let temp = tempdir().unwrap();