diff --git a/CHANGELOG.md b/CHANGELOG.md index b7c1b5e7..c3694867 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,36 @@ Entries that change an on-disk format or a response shape say so. ## [Unreleased] ### Added +- `POST /contexts/{name}/schema/audit` and `POST + /contexts/{name}/schema/validate` (#385, S7 of #218's ADR 0009 split + §10). `audit` judges every live association against the resident + document; `validate` takes a *proposed* document instead and never + persists it — the pre-flight §7.1 promises before a `strict` flip. + Both share one judgment (`schema_audit`, `src/api/schema.rs`) built + on the same `schema_issues`/`SchemaEnv` pure check every write + entrance already uses (S3, #381), so a finding here is exactly what + `strict` would refuse for the identical fact. Deliberately + mode-independent: §7.1's whole reason for this route to exist is that + pre-existing violations are otherwise invisible, so `audit`/`validate` + judge as `strict` would regardless of the document's actual `mode` — + an `off` or `warn` context reports the same violations a `strict` one + does. Five candidates-not-verdicts sections in one `DriftAudit`-shaped + response, framed exactly like `audit_vocabulary`'s own doc — nothing + is ever auto-applied: `violations` (domain/range mismatches, the only + section that pages, worst-magnitude-first like every other match + list), `untyped_concepts`, `undeclared_types` (§6.2, always reported + regardless of `closed_labels`), `unknown_labels` (§6.4, only under + `closed_labels`, never naming `schema:type` itself), and + `reserved_alias_conflicts` (§6.3 guard 2's install-time bullet, read + back — only reachable through `validate`, since `PUT /schema` itself + already refuses to install over such a conflict). Deprecated-relation + usage is explicitly **not** in scope here — §9.2 defers it until a + follow-up ADR gives the document a field to mark a relation + deprecated. Both routes are `Role::Read` and O(edges) with no cheap + variant, joining the unconditional heavy-ops group beside + `audit_vocabulary`/`compact_context` rather than `audit_drift`'s + conditional-extension pattern. `audit_schema`/`validate_schema` MCP + tools round-trip onto the same two routes. - The `taguru_schema` export/import stream record and its replication parity (#384, S6 of #218's ADR 0009 split §13). `export::render` emits it as the FIRST line of a context's stream, only when the diff --git a/src/api.rs b/src/api.rs index e274bf8e..f26a88ae 100644 --- a/src/api.rs +++ b/src/api.rs @@ -70,7 +70,7 @@ pub use import::{ pub(crate) use import::{import_outcome, schema_import_outcome}; pub use recall::{cross_query, cross_recall, query, recall}; pub use resolve::{explain_resolve, explain_resolve_label, resolve, resolve_label}; -pub use schema::{get_schema, put_schema}; +pub use schema::{audit_schema, get_schema, put_schema, validate_schema}; pub use sources::{ citation, cross_search_passages, explain_search_passages, list_sources, lookup_passages, retract_source, search_passages, store_passages, diff --git a/src/api/associations.rs b/src/api/associations.rs index 434e260a..bb4b670f 100644 --- a/src/api/associations.rs +++ b/src/api/associations.rs @@ -11,7 +11,7 @@ use taguru::deadline::Deadline; use crate::metrics::ErrorKind; use crate::registry::{AppState, AssocOp}; -use crate::schema::{SchemaCheckInput, SchemaEnv, SchemaMode, schema_issues}; +use crate::schema::{IssuePath, SchemaCheckInput, SchemaEnv, SchemaMode, schema_issues}; use super::{ AppJson, AppPath, ErrorCode, Issue, MAX_ASSOCIATION_WEIGHT, MAX_ASSOCIATIONS_PER_REQUEST, @@ -295,7 +295,7 @@ pub async fn add_associations( Ok(env) => env, Err(failure) => return access_error(&state, failure, &name, started_at), }; - let check = schema_issues(&env, &associations, ""); + let check = schema_issues(&env, &associations, IssuePath::Request { prefix: "" }); // ADR 0009 §6.3 guard 2: a reserved-label conflict refuses // regardless of mode — this route has no inline `labels` // declaration today, so `reserved` is always empty in diff --git a/src/api/schema.rs b/src/api/schema.rs index 0581bd8a..90d9a3d6 100644 --- a/src/api/schema.rs +++ b/src/api/schema.rs @@ -1,20 +1,34 @@ //! `GET`/`PUT /contexts/{name}/schema` (#380, S2 of #218's ADR 0009 //! split) — the management routes over the schema document S1 (#379) -//! already knows how to validate and persist. Nothing here enforces -//! anything against the graph; that is `schema_issues`' job (S3, #381). +//! already knows how to validate and persist — plus `POST .../schema/audit` +//! and `POST .../schema/validate` (#385, S7, ADR 0009 §10): the two +//! read-only routes that DO judge the graph, sharing [`schema_audit`] +//! with each other and `schema_issues`/[`SchemaEnv`] with every write +//! entrance (S3, #381), so this module's own reading of "domain/range +//! violation" can never drift from what `strict` would actually refuse. +use std::collections::{BTreeMap, BTreeSet}; +use std::sync::Arc; use std::time::Instant; use axum::extract::State; use axum::response::Response; +use serde::{Deserialize, Serialize}; +use taguru::context::Association; use taguru::deadline::Deadline; use crate::metrics::ErrorKind; -use crate::registry::{AppState, PutSchemaError}; -use crate::schema::{self, SchemaDocument}; +use crate::registry::{AccessError, AppState, AssocOp, PutSchemaError}; +use crate::schema::{ + self, IssuePath, SCHEMA_TYPE_LABEL, SchemaCheckInput, SchemaDocument, SchemaEnv, schema_issues, +}; -use super::{AppJson, AppPath, ErrorCode, deadline_exceeded, error, key_name, not_found, ok}; +use super::{ + AppBytes, AppJson, AppPath, AssociationOut, ErrorCode, Issue, MatchCursor, access_error, + association_out, deadline_exceeded, error, key_name, locator_keys, not_found, ok, + optional_body, page_by, +}; /// One directory row by name's schema-document twin — the resident /// document as `install`ed, or a 404 distinguishing "this context has @@ -145,3 +159,427 @@ pub async fn put_schema( } } } + +/// Ceiling on how many entries each of [`SchemaAudit`]'s four small +/// sections lists — `total` still reports the true count past this cap, +/// the same "count everything, list a bounded prefix" contract +/// [`crate::api::MAX_LISTED_ISSUES`] applies to a validation refusal's +/// issue list, sized for a context whose live vocabulary can be far +/// larger than one audit response should carry. +const MAX_AUDIT_NAMES: usize = 100; + +/// One of [`SchemaAudit`]'s three name-list sections: the true count of +/// names this check surfaced, and a name-ordered prefix of them capped +/// at [`MAX_AUDIT_NAMES`]. +#[derive(Debug, Serialize)] +pub struct AuditNames { + pub total: usize, + pub names: Vec, +} + +fn audit_names(names: BTreeSet) -> AuditNames { + let total = names.len(); + AuditNames { + total, + names: names.into_iter().take(MAX_AUDIT_NAMES).collect(), + } +} + +/// [`AuditNames`]'s sibling for `reserved_alias_conflicts` (`alias -> +/// canonical`, not a bare name set) — capped the same way at +/// [`MAX_AUDIT_NAMES`]. An operator can in principle register many +/// aliases resolving to `schema:type` before ever installing a schema +/// (ADR 0009 §6.3 guard 1 leaves the label ordinary until then), so this +/// section is bounded defensively like every other one here, even +/// though a well-behaved server keeps it empty once a schema exists — +/// `PUT /schema`'s own migration-boundary guard already refuses to +/// install over a pre-existing conflict. +#[derive(Debug, Serialize)] +pub struct AuditAliases { + pub total: usize, + pub aliases: BTreeMap, +} + +fn audit_aliases(aliases: BTreeMap) -> AuditAliases { + let total = aliases.len(); + AuditAliases { + total, + aliases: aliases.into_iter().take(MAX_AUDIT_NAMES).collect(), + } +} + +/// One live association [`schema_issues`] flagged, alongside every issue +/// it raised. An audit finding travels with the edge it is about, +/// unlike a write entrance's issues, which travel with a request +/// coordinate (see [`IssuePath::Request`]) instead — this route uses +/// [`IssuePath::Edge`]. +#[derive(Debug, Serialize)] +pub struct SchemaViolationOut { + pub association: AssociationOut, + pub issues: Vec, +} + +/// ADR 0009 §10's schema audit: five independent read-only checks over +/// the live graph in one response, in `DriftAudit`'s shape +/// (`src/api/vocabulary.rs`) — only `violations`/`total` page (the same +/// `page_by` rank every other match list uses); the other four +/// sections are small enough in practice to return whole, each capped at +/// [`MAX_AUDIT_NAMES`] with its own true count. Framed exactly like +/// `audit_vocabulary`'s own doc (`src/api/vocabulary.rs:38-41`): +/// candidates for review, not verdicts — this audit never auto-applies a +/// fix. +#[derive(Debug, Serialize)] +pub struct SchemaAudit { + /// `violations`' count before `limit`/`after` — constant across + /// pages, the same convention every other paged section in this API + /// uses. + pub total: usize, + pub violations: Vec, + /// Live concepts asserting no `schema:type` of their own, excluding + /// concepts that are themselves asserted type names (ADR 0009 §6.3 + /// exclusion 3's own reasoning: `Brewery` reading as "untyped" would + /// be noise, not signal) and every concept already declared in + /// `types` (a declared type with no `schema:type` assertion of its + /// own is not what this section is about). Reported unconditionally + /// — untyped concepts are legal in every mode, §6.1 — as a candidate + /// list only. + pub untyped_concepts: AuditNames, + /// Type names actually asserted (the object of some live + /// `schema:type` edge) but absent from the document's `types` map — + /// always reported, unconditional on `closed_labels` (§6.2): an + /// undeclared type is never a violation, only a signal a schema + /// author may want to see. + pub undeclared_types: AuditNames, + /// Live relation labels with no entry in `relations` — populated + /// only when the document sets `closed_labels` (§6.4), since the + /// question is meaningless otherwise. `schema:type` itself never + /// appears here even when `closed_labels` is set (§6.4's own + /// carve-out for it). The same fact also drives a `violations` entry + /// on every individual edge carrying such a label; this section + /// answers a different question — "what vocabulary would `relations` + /// need to gain" — not "which edges would `strict` refuse." + pub unknown_labels: AuditNames, + /// ADR 0009 §6.3 guard 2's install-time bullet, read back: every + /// already-persisted label alias whose canonical spelling is the + /// reserved `schema:type` label (`alias → canonical`). Only possible + /// for an alias created before this context ever had a schema — `PUT + /// /schema` itself refuses to install over one, so this section + /// reads empty for any context whose schema installed cleanly; + /// non-empty here means the next `PUT /schema` (this document or any + /// other) will refuse until the alias is renamed. + pub reserved_alias_conflicts: AuditAliases, +} + +/// Request body for `POST /contexts/{name}/schema/audit` — an absent +/// body means every default below; paging over `violations` follows the +/// same contract `DriftAuditRequest` (`src/api/vocabulary.rs`) applies to +/// `unsourced`. +#[derive(Debug, Default, Deserialize)] +#[serde(default)] +pub struct SchemaAuditRequest { + /// Omitted means 100, capped at 1000 — pages exactly like recall, + /// query, and every other match list. + pub limit: Option, + /// Resume past a previous page's last violation — see [`MatchCursor`]. + pub after: Option, +} + +/// Request body for `POST /contexts/{name}/schema/validate` — the +/// *proposed* document to dry-run, alongside the same paging fields +/// [`SchemaAuditRequest`] carries. Wrapped rather than reusing `PUT +/// /schema`'s bare-document body: [`SchemaDocument`] is itself +/// `deny_unknown_fields`, so a sibling `limit`/`after` could never sit +/// beside it there without changing that struct's own wire contract. +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SchemaValidateRequest { + pub document: SchemaDocument, + #[serde(default)] + pub limit: Option, + #[serde(default)] + pub after: Option, +} + +/// What [`schema_audit`]'s `read_context` closure computes. Paging and +/// marker resolution both need the registry (`AppState`), unreachable +/// from inside the closure, so this travels back out whole and is +/// finished outside `read_context` — the same shape `audit_drift` +/// (`src/api/vocabulary.rs`) already uses for its own two sweeps. +struct RawAudit { + violations: Vec<(Association, Vec)>, + untyped_concepts: BTreeSet, + undeclared_types: BTreeSet, + unknown_labels: BTreeSet, + reserved_alias_conflicts: BTreeMap, +} + +/// The one function [`audit_schema`] and [`validate_schema`] both call — +/// `schema` is already installed either way (a resident document for the +/// former, `schema::install`'s output for the latter, never persisted), +/// so from here on the two routes cannot answer differently for the same +/// document. ADR 0009 §10 fixes this shared shape in its own text: "both +/// routes are O(edges) with no cheap variant." +fn schema_audit( + state: &AppState, + name: &str, + schema: Arc, + limit: Option, + after: Option<&MatchCursor>, + deadline: Deadline, +) -> Result { + // ADR 0009 §7.1: "pre-existing violations are visible only through + // the explicitly-invoked, read-only audit" — the whole reason this + // route exists is to surface what `strict` WOULD refuse, regardless + // of what mode the document is actually in right now. + // `schema_issues` only branches on `mode` to skip work when it is + // `Off`; forcing it to `Strict` here is exactly "judge every op," + // nothing more (see [`schema::InstalledSchema::enforcing`]). + let enforcing = schema::InstalledSchema::enforcing(&schema); + + let raw = state + .read_context(name, |context| -> Result { + let live: Vec = context + .all_associations(deadline) + .map_err(|_| AccessError::DeadlineExceeded)? + .into_iter() + .filter(|association| association.count > 0) + .collect(); + + let mut live_concepts: BTreeSet = BTreeSet::new(); + let mut live_labels: BTreeSet = BTreeSet::new(); + let mut typed_concepts: BTreeSet = BTreeSet::new(); + let mut asserted_type_names: BTreeSet = BTreeSet::new(); + let mut fact_edges: Vec = Vec::with_capacity(live.len()); + let mut fact_ops: Vec = Vec::with_capacity(live.len()); + + for association in live { + live_concepts.insert(association.subject.clone()); + live_concepts.insert(association.object.clone()); + live_labels.insert(association.label.clone()); + if association.label == SCHEMA_TYPE_LABEL { + typed_concepts.insert(association.subject.clone()); + asserted_type_names.insert(association.object.clone()); + } else { + fact_ops.push(AssocOp { + subject: association.subject.clone(), + label: association.label.clone(), + object: association.object.clone(), + weight: association.weight, + source: None, + paragraph: None, + }); + fact_edges.push(association); + } + } + + // ADR 0009 §6.3 guard 2's install-time bullet, read back: an + // alias created before this context ever had a schema could + // resolve to the reserved label without ever having been + // caught by `add_label_alias`'s or a batch's own guard. + let reserved_alias_conflicts: BTreeMap = context + .label_aliases() + .into_iter() + .filter(|(_, canonical)| *canonical == SCHEMA_TYPE_LABEL) + .map(|(alias, canonical)| (alias.to_string(), canonical.to_string())) + .collect(); + + // `SchemaEnv::build` runs its own live `query_any` sweep + // (scoped to the concepts `fact_ops` mentions) on top of the + // full scan `all_associations` already spent — recheck the + // deadline before paying for it, the same pre-flight every + // other heavy step in this closure gets. + if deadline.expired() { + return Err(AccessError::DeadlineExceeded); + } + + // The same union every write entrance builds (S3, #381) — + // reused verbatim rather than re-deriving type membership by + // hand, so this audit's domain/range answer can never + // disagree with what `strict` would actually refuse for the + // identical set of facts. + let env = SchemaEnv::build( + context, + SchemaCheckInput { + schema: Arc::clone(&enforcing), + ops: &fact_ops, + declared_labels: &BTreeMap::new(), + retracted_source: None, + }, + ); + + let mut violations = Vec::new(); + for (edge, op) in fact_edges.into_iter().zip(fact_ops.iter()) { + if deadline.expired() { + return Err(AccessError::DeadlineExceeded); + } + let check = schema_issues(&env, std::slice::from_ref(op), IssuePath::Edge); + if !check.violations.is_empty() { + violations.push((edge, check.violations)); + } + } + + let document = enforcing.document(); + let untyped_concepts = live_concepts + .into_iter() + .filter(|concept| { + !typed_concepts.contains(concept) && !asserted_type_names.contains(concept) + }) + .filter(|concept| !document.types.contains_key(concept)) + .collect(); + let undeclared_types = asserted_type_names + .into_iter() + .filter(|type_name| !document.types.contains_key(type_name)) + .collect(); + let unknown_labels = if document.closed_labels { + live_labels + .into_iter() + .filter(|label| label != SCHEMA_TYPE_LABEL) + .filter(|label| !document.relations.contains_key(label)) + .collect() + } else { + BTreeSet::new() + }; + + Ok(RawAudit { + violations, + untyped_concepts, + undeclared_types, + unknown_labels, + reserved_alias_conflicts, + }) + }) + .and_then(std::convert::identity)?; + + // A read like `audit_drift`/`unreachable_from`: zero findings is the + // audit succeeding, not a miss, so it never counts as an empty read. + state.note_read(name, false); + + let (total, violations) = page_by(raw.violations, limit, after, |(association, _)| { + ( + association.weight, + association.subject.as_str(), + association.label.as_str(), + association.object.as_str(), + ) + }); + let markers = state.resolve_markers( + name, + locator_keys(violations.iter().map(|(association, _)| association)), + ); + let violations = violations + .into_iter() + .map(|(association, issues)| SchemaViolationOut { + association: association_out(association, &markers), + issues, + }) + .collect(); + + Ok(SchemaAudit { + total, + violations, + untyped_concepts: audit_names(raw.untyped_concepts), + undeclared_types: audit_names(raw.undeclared_types), + unknown_labels: audit_names(raw.unknown_labels), + reserved_alias_conflicts: audit_aliases(raw.reserved_alias_conflicts), + }) +} + +/// `POST /contexts/{name}/schema/audit` (#385, S7 of #218's ADR 0009 +/// split §10): judges every live association against `name`'s installed +/// document — the pre-existing violations `strict` itself can never +/// surface on its own (§7.1), since a write entrance only ever judges a +/// write as it happens, never what already landed before the schema (or +/// its current mode) existed. +pub async fn audit_schema( + State(state): State, + AppPath(name): AppPath, + axum::Extension(deadline): axum::Extension, + AppBytes(body): AppBytes, +) -> Response { + let started_at = Instant::now(); + let request: SchemaAuditRequest = match optional_body(&body, started_at) { + Ok(request) => request, + Err(refusal) => return *refusal, + }; + if deadline.expired() { + return deadline_exceeded(started_at); + } + tokio::task::block_in_place(|| { + // `schema_of`'s slow path takes this entry's write lock, so it + // must resolve BEFORE `schema_audit`'s own `read_context` call — + // the same ordering `get_schema`, `vocabulary_audit`, and the + // associations handler's pre-write arm all already depend on + // (see `AppState::hidden_label`'s own doc for why the two + // cannot nest). + match state.schema_of(&name) { + None => not_found(&name, started_at), + Some(Ok(None)) => error( + ErrorCode::NoSchema, + format!("context '{name}' has no schema document"), + started_at, + ), + Some(Err(message)) => { + // Same posture as `get_schema`'s Load arm: the detail can + // name a filesystem path, so it is logged, never + // forwarded. + tracing::warn!(context = %name, error = %message, "schema load failed"); + state.metrics().record_error(ErrorKind::Load); + error( + ErrorCode::Internal, + format!("context '{name}' schema could not be loaded — see server logs"), + started_at, + ) + } + Some(Ok(Some(installed))) => match schema_audit( + &state, + &name, + installed, + request.limit, + request.after.as_ref(), + deadline, + ) { + Ok(audit) => ok(audit, started_at), + Err(failure) => access_error(&state, failure, &name, started_at), + }, + } + }) +} + +/// `POST /contexts/{name}/schema/validate` (#385, S7, §10): the same +/// judgment as [`audit_schema`], but over a PROPOSED document that is +/// validated and evaluated without ever being persisted — the pre-flight +/// §7.1 promises before a `strict` flip. Works identically whether `name` +/// already has an installed schema or none at all; either way this call +/// never reads or writes the resident document. +pub async fn validate_schema( + State(state): State, + AppPath(name): AppPath, + axum::Extension(deadline): axum::Extension, + AppJson(request): AppJson, +) -> Response { + let started_at = Instant::now(); + let SchemaValidateRequest { + document, + limit, + after, + } = request; + let installed = match schema::install(document) { + Ok(installed) => Arc::new(installed), + Err(violation) => { + return error( + ErrorCode::InvalidArgument, + violation.to_string(), + started_at, + ); + } + }; + if deadline.expired() { + return deadline_exceeded(started_at); + } + match tokio::task::block_in_place(|| { + schema_audit(&state, &name, installed, limit, after.as_ref(), deadline) + }) { + Ok(audit) => ok(audit, started_at), + Err(failure) => access_error(&state, failure, &name, started_at), + } +} diff --git a/src/auth.rs b/src/auth.rs index ce1c6cb8..fc6c2772 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -791,6 +791,8 @@ pub(crate) fn required_role(method: &Method, route: &str) -> Role { | (&Method::POST, "/contexts/{name}/unreachable_from") | (&Method::POST, "/contexts/{name}/vocabulary/audit") | (&Method::POST, "/contexts/{name}/drift/audit") + | (&Method::POST, "/contexts/{name}/schema/audit") + | (&Method::POST, "/contexts/{name}/schema/validate") | (&Method::GET, "/contexts/{name}/communities") | (&Method::POST, "/contexts/{name}/communities/search") | (&Method::POST, "/contexts/{name}/evidence") @@ -1611,6 +1613,22 @@ mod tests { ); } + /// ADR 0009 §12.5: both new §10 routes are read-only judgments over + /// the live graph (`audit`) or a never-persisted proposed document + /// (`validate`) — neither writes anything, so both classify beside + /// `vocabulary/audit` and `drift/audit`, not beside `PUT /schema`. + #[test] + fn schema_audit_and_validate_are_read() { + assert_eq!( + required_role(&Method::POST, "/contexts/{name}/schema/audit"), + Role::Read + ); + assert_eq!( + required_role(&Method::POST, "/contexts/{name}/schema/validate"), + Role::Read + ); + } + /// The three `/explain` endpoints are read-only diagnostics for /// their base endpoint — same role, or fail-closed silently /// demotes a scoped reader to Admin the moment they ask why a diff --git a/src/context/query.rs b/src/context/query.rs index 42e50f20..478da354 100644 --- a/src/context/query.rs +++ b/src/context/query.rs @@ -1,11 +1,37 @@ use std::collections::{HashMap, HashSet}; +use crate::deadline::{Deadline, DeadlineExceeded}; + use super::{ Association, ConceptDescription, Context, EdgeFollow, EdgeId, LabelId, LabelUsage, keep_narrowest_anchor, }; impl Context { + /// Every edge in the context, dead ones included, in edge-id order — + /// the same population `query_any(&[], &[], &[])`'s degenerate arm + /// returns, but deadline-checked like `unsourced_edges`/ + /// `dead_canonical_aliases` for a caller (ADR 0009 §10's schema + /// audit) that means to walk every one of them rather than stumbling + /// into the full scan by accident. `query_any` itself stays + /// deadline-free: every one of its OTHER callers passes a + /// constrained position, so the full-scan arm never runs on their + /// behalf, and adding a `Result` to every `query`/`recall` caller for + /// a branch none of them take would be pure churn. + pub fn all_associations( + &self, + deadline: Deadline, + ) -> Result, DeadlineExceeded> { + let mut out = Vec::with_capacity(self.edges.len()); + for edge_id in 0..self.edges.len() as u32 { + if deadline.expired() { + return Err(DeadlineExceeded); + } + out.push(self.association(edge_id)); + } + Ok(out) + } + /// Recalls every association touching `cue`, whether it appears as the /// subject, the relation label, or the object. This lets a relation /// label (e.g. "好き") act as a search cue in its own right, not just a diff --git a/src/ingest.rs b/src/ingest.rs index d48aa079..b181b658 100644 --- a/src/ingest.rs +++ b/src/ingest.rs @@ -2790,7 +2790,11 @@ fn predicted_schema_rejection( retracted_source: Some(&batch.source), }, ); - crate::schema::schema_issues(&env, &ops, "") + crate::schema::schema_issues( + &env, + &ops, + crate::schema::IssuePath::Request { prefix: "" }, + ) }) .map_err(ApplyRefusal::Access)?; diff --git a/src/limits.rs b/src/limits.rs index f90cb528..bd23f442 100644 --- a/src/limits.rs +++ b/src/limits.rs @@ -105,8 +105,9 @@ pub(crate) fn shed( /// A shared, non-queuing permit pool for the whole-context CPU/disk /// sweeps exposed by the API. Unlike the global in-flight ceiling this -/// gate is applied only to `audit_vocabulary`, `compact_context`, and -/// `audit_drift` (which runs the same pairwise scan as +/// gate is applied only to `audit_vocabulary`, `compact_context`, +/// `audit_schema`, `validate_schema` (ADR 0009 §10 — both unconditionally +/// O(edges)), and `audit_drift` (which runs the same pairwise scan as /// `audit_vocabulary` when `include_twins` is set); ordinary requests /// retain the rest of the worker pool during a burst. #[derive(Clone)] diff --git a/src/main.rs b/src/main.rs index 6c985b2c..5c8a4146 100644 --- a/src/main.rs +++ b/src/main.rs @@ -774,6 +774,15 @@ fn routes( "/contexts/{name}/communities", get(api::analyze_communities), ) + // ADR 0009 §10: both O(edges), with no cheap default path — + // unlike `audit_drift` just below, neither ever conditionally + // skips the full scan, so both join this unconditional group + // rather than carrying the limiter as an extension. + .route("/contexts/{name}/schema/audit", post(api::audit_schema)) + .route( + "/contexts/{name}/schema/validate", + post(api::validate_schema), + ) .route_layer(axum::middleware::from_fn_with_state( heavy_ops_limiter.clone(), limits::enforce_heavy_ops, diff --git a/src/mcp.rs b/src/mcp.rs index e498f73f..896de38e 100644 --- a/src/mcp.rs +++ b/src/mcp.rs @@ -65,6 +65,10 @@ mod tests { "subject": "s", "label": "l", "object": "o", "schema": 1, "mode": "strict", "closed_labels": false, "types": {}, "relations": {}, + "document": { + "schema": 1, "mode": "strict", "closed_labels": false, + "types": {}, "relations": {}, + }, }); for tool in tool_definitions() { let name = tool["name"].as_str().expect("definitions carry names"); @@ -269,6 +273,10 @@ mod tests { "subject": "s", "label": "l", "object": "o", "schema": 1, "mode": "strict", "closed_labels": false, "types": {}, "relations": {}, + "document": { + "schema": 1, "mode": "strict", "closed_labels": false, + "types": {}, "relations": {}, + }, }); let cases = [ ("rename_context", "to"), @@ -299,6 +307,7 @@ mod tests { ("put_schema", "closed_labels"), ("put_schema", "types"), ("put_schema", "relations"), + ("validate_schema", "document"), ]; for (tool, key) in cases { let mut arguments = base.clone(); @@ -598,12 +607,14 @@ mod tests { /// the downstream Rust struct would reject anyway. #[test] fn search_and_audit_tools_advertise_after() { - let cases: [(&str, &[&str]); 5] = [ + let cases: [(&str, &[&str]); 7] = [ ("query", &["weight", "subject", "label", "object"]), ("recall", &["weight", "subject", "label", "object"]), ("explore", &["distance", "subject", "label", "object"]), ("audit_coverage", &["weight", "subject", "label", "object"]), ("audit_drift", &["weight", "subject", "label", "object"]), + ("audit_schema", &["weight", "subject", "label", "object"]), + ("validate_schema", &["weight", "subject", "label", "object"]), ]; for (name, required) in cases { let tool = tool_definitions() @@ -672,6 +683,21 @@ mod tests { let (_, _, body) = route_tool("audit_drift", &json!({"context": "sake", "after": cursor})).unwrap(); assert_eq!(body.unwrap()["after"], cursor); + + let (_, _, body) = + route_tool("audit_schema", &json!({"context": "sake", "after": cursor})).unwrap(); + assert_eq!(body.unwrap()["after"], cursor); + + let document = json!({ + "schema": 1, "mode": "strict", "closed_labels": false, + "types": {}, "relations": {} + }); + let (_, _, body) = route_tool( + "validate_schema", + &json!({"context": "sake", "document": document, "after": cursor}), + ) + .unwrap(); + assert_eq!(body.unwrap()["after"], cursor); } #[test] diff --git a/src/mcp/route.rs b/src/mcp/route.rs index 03fae7e0..f6cc5ad0 100644 --- a/src/mcp/route.rs +++ b/src/mcp/route.rs @@ -483,6 +483,19 @@ pub fn route_tool( ], )), ), + "audit_schema" => ( + "POST", + format!("{}/schema/audit", context_path("context")?), + Some(pick(arguments, &["limit", "after"])), + ), + "validate_schema" => { + need_present(arguments, "document")?; + ( + "POST", + format!("{}/schema/validate", context_path("context")?), + Some(pick(arguments, &["document", "limit", "after"])), + ) + } _ => return Err(format!("unknown tool '{name}'")), }) } diff --git a/src/mcp/schema.rs b/src/mcp/schema.rs index 0d000c30..ab7261b7 100644 --- a/src/mcp/schema.rs +++ b/src/mcp/schema.rs @@ -763,6 +763,77 @@ pub(super) fn tool_definitions() -> Vec { &["context"], ), ), + ( + "audit_schema", + "ADR 0009 schema audit: judges every live association against the installed schema document, surfacing what a `strict` flip would refuse without waiting for one — the pre-existing violations `strict` itself can never show (it only judges a write as it happens). Four candidates-not-verdicts sections in one call: violations (domain/range mismatches, paginated, worst-first), untyped_concepts (asserted no schema:type of their own), undeclared_types (asserted type names absent from `types`, always reported), unknown_labels (relation labels absent from `relations`, only when closed_labels is set), and reserved_alias_conflicts (a persisted label alias resolving to the reserved schema:type label). Never auto-applies a fix. 404 if the context has no installed schema.", + object_schema( + json!({ + "context": context, + "limit": { "type": "integer", "minimum": 0, "description": "default 100, capped at 1000 — pages violations only" }, + "after": { + "type": "object", + "description": "resume past the previous page's last violation — copy every field verbatim from it. total stays constant across pages", + "properties": { + "weight": { "type": "number" }, + "subject": { "type": "string" }, + "label": { "type": "string" }, + "object": { "type": "string" } + }, + "required": ["weight", "subject", "label", "object"] + } + }), + &["context"], + ), + ), + ( + "validate_schema", + "Dry-run of a PROPOSED schema document (same shape as put_schema's own arguments) against the live graph — never persisted, and reads the installed schema for no purpose (the proposed document alone drives the judgment, even in a context with none installed yet). The pre-flight before flipping mode to strict: run this first with the intended document to see every violations()/untyped_concepts/undeclared_types/unknown_labels finding it would produce once installed. Same response shape as audit_schema.", + object_schema( + json!({ + "context": context, + "document": { + "type": "object", + "description": "the proposed schema document, same shape as put_schema's own arguments (schema, mode, closed_labels, types, relations), never installed", + "properties": { + "schema": { "type": "integer", "description": "document format version this binary reads (currently 1)" }, + "mode": { "type": "string", "enum": ["off", "warn", "strict"] }, + "closed_labels": { "type": "boolean" }, + "types": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { "is_a": { "type": "array", "items": { "type": "string" } } } + } + }, + "relations": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "domain": { "type": "array", "items": { "type": "string" } }, + "range": { "type": "array", "items": { "type": "string" } } + } + } + } + }, + "required": ["schema", "mode", "closed_labels", "types", "relations"] + }, + "limit": { "type": "integer", "minimum": 0, "description": "default 100, capped at 1000 — pages violations only" }, + "after": { + "type": "object", + "description": "resume past the previous page's last violation — copy every field verbatim from it. total stays constant across pages", + "properties": { + "weight": { "type": "number" }, + "subject": { "type": "string" }, + "label": { "type": "string" }, + "object": { "type": "string" } + }, + "required": ["weight", "subject", "label", "object"] + } + }), + &["context", "document"], + ), + ), ( "flush", "Persist every dirty context to disk now; answers the flushed names (admin role). The backup handshake's first half: flush, then snapshot the data directory — the same discipline the operator docs describe, reachable by an agent tending its own memory.", diff --git a/src/schema.rs b/src/schema.rs index 35f8c7a5..f6c5edde 100644 --- a/src/schema.rs +++ b/src/schema.rs @@ -25,6 +25,7 @@ use std::collections::{BTreeMap, BTreeSet}; use std::fs; use std::io; use std::path::Path; +use std::sync::Arc; use serde::{Deserialize, Serialize}; @@ -38,7 +39,7 @@ use crate::storage::write_atomic; // `predicted_schema_rejection`/`preview_batch`, `src/ingest.rs`) and // S5 (#383, the associations pre-write arm, `src/api/associations.rs`). mod check; -pub(crate) use check::{SchemaCheckInput, SchemaEnv, schema_issues}; +pub(crate) use check::{IssuePath, SchemaCheckInput, SchemaEnv, schema_issues}; /// This binary's only readable document shape. Independent of /// `BATCH_VERSION`/`GROUP_VERSION`/`IMAGE_VERSION` — [`GroupRecord`]'s @@ -259,6 +260,33 @@ impl InstalledSchema { closure.insert(type_name.to_string()); closure } + + /// ADR 0009 §10: the audit judges `off`/`warn` documents by the same + /// domain/range predicate `strict` would apply — "pre-existing + /// violations are visible only through the explicitly-invoked, + /// read-only audit" (§7.1) only holds if the audit does not itself + /// defer to `mode`. `schema_issues` only branches on `mode` to + /// short-circuit `SchemaEnv::build`'s live read and its own violation + /// pass when `mode == Off` — so forcing `mode` to `Strict` here is + /// exactly "judge every op," nothing more, and the precomputed `is_a` + /// closures carry over unchanged since the hierarchy never depends on + /// mode. Returns `schema` unchanged (no clone) when already + /// non-`Off` — an associated function taking `&Arc` rather + /// than an `Arc`-receiver method, so `InstalledSchema` itself stays + /// usable without always being wrapped in one. + pub(crate) fn enforcing(schema: &Arc) -> Arc { + if schema.document.mode == SchemaMode::Off { + Arc::new(InstalledSchema { + document: SchemaDocument { + mode: SchemaMode::Strict, + ..schema.document.clone() + }, + ancestors: schema.ancestors.clone(), + }) + } else { + Arc::clone(schema) + } + } } /// Validates a parsed document and precomputes its `is_a` closures — diff --git a/src/schema/check.rs b/src/schema/check.rs index 4b0d2e5c..273065e7 100644 --- a/src/schema/check.rs +++ b/src/schema/check.rs @@ -365,23 +365,54 @@ pub(crate) struct SchemaCheck { pub(crate) violations: Vec, } +/// `Issue.path`'s grammar. A write entrance names a coordinate within +/// its own request body (ADR 0009 §8.2's two prefixes); ADR 0009 §10's +/// audit has no such coordinate — the offending association travels +/// alongside its issues in `SchemaViolationOut`, so `path` there need +/// only name which side of that one association fired. +pub(crate) enum IssuePath<'a> { + /// `{prefix}associations[{index}].{side}` — `""` for + /// `POST /contexts/{name}/associations` (paths read + /// `associations[{i}]...`), `"batches[{b}]."` for + /// `POST /import`/`taguru import` (paths read + /// `batches[{b}].associations[{a}]...`). + Request { prefix: &'a str }, + /// `subject` / `object` / `label` alone — §10's audit calls + /// [`schema_issues`] once per association (`ops.len() == 1`), so no + /// request-body index ever applies. + Edge, +} + +impl IssuePath<'_> { + fn associations_field(&self, index: usize, side: &str) -> String { + match self { + Self::Request { prefix } => format!("{prefix}associations[{index}].{side}"), + Self::Edge => side.to_string(), + } + } + + fn labels_field(&self, alias: &str) -> String { + match self { + Self::Request { prefix } => format!("{prefix}labels['{alias}']"), + Self::Edge => format!("labels['{alias}']"), + } + } +} + /// The one function every schema-checking write entrance calls — S4 /// (#382, `predicted_schema_rejection`/`preview_batch`) and S5 (#383, /// the associations handler's pre-write arm) alike, so the two -/// entrances cannot drift apart (this module's own doc). `env` must -/// have been built from this exact `ops` slice — [`SchemaEnv`]'s maps -/// only cover the spellings those ops mention. `path_prefix` folds ADR -/// 0009 §8.2's two path grammars into one: `""` for -/// `POST /contexts/{name}/associations` (paths read -/// `associations[{i}]...`), `"batches[{b}]."` for -/// `POST /import`/`taguru import` (paths read -/// `batches[{b}].associations[{a}]...`). -pub(crate) fn schema_issues(env: &SchemaEnv, ops: &[AssocOp], path_prefix: &str) -> SchemaCheck { +/// entrances cannot drift apart (this module's own doc); S7 (#385)'s +/// audit reuses it too, one association at a time, via +/// [`IssuePath::Edge`]. `env` must have been built from this exact +/// `ops` slice — [`SchemaEnv`]'s maps only cover the spellings those +/// ops mention. +pub(crate) fn schema_issues(env: &SchemaEnv, ops: &[AssocOp], path: IssuePath<'_>) -> SchemaCheck { let reserved = env .declared_labels .iter() .filter(|(_, canonical)| canonical.as_str() == SCHEMA_TYPE_LABEL) - .map(|(alias, _)| reserved_alias_issue(path_prefix, alias)) + .map(|(alias, _)| reserved_alias_issue(&path, alias)) .collect(); let document = env.schema.document(); @@ -410,7 +441,7 @@ pub(crate) fn schema_issues(env: &SchemaEnv, ops: &[AssocOp], path_prefix: &str) let subject_types = env.types_for(&op.subject); if side_violates(&relation.domain, subject_types) { violations.push(Issue::domain( - format!("{path_prefix}associations[{index}].subject"), + path.associations_field(index, "subject"), expected_types(&op.subject, &relation.domain, resolved_label), actual_types(subject_types), )); @@ -418,7 +449,7 @@ pub(crate) fn schema_issues(env: &SchemaEnv, ops: &[AssocOp], path_prefix: &str) let object_types = env.types_for(&op.object); if side_violates(&relation.range, object_types) { violations.push(Issue::range_type( - format!("{path_prefix}associations[{index}].object"), + path.associations_field(index, "object"), expected_types(&op.object, &relation.range, resolved_label), actual_types(object_types), )); @@ -429,7 +460,7 @@ pub(crate) fn schema_issues(env: &SchemaEnv, ops: &[AssocOp], path_prefix: &str) // never mind whether `closed_labels` is set. None if document.closed_labels => { violations.push(Issue::undeclared_label( - format!("{path_prefix}associations[{index}].label"), + path.associations_field(index, "label"), "a label declared in this context's schema (closed_labels)", )); } @@ -443,9 +474,9 @@ pub(crate) fn schema_issues(env: &SchemaEnv, ops: &[AssocOp], path_prefix: &str) } } -fn reserved_alias_issue(path_prefix: &str, alias: &str) -> Issue { +fn reserved_alias_issue(path: &IssuePath<'_>, alias: &str) -> Issue { Issue::conflict( - format!("{path_prefix}labels['{alias}']"), + path.labels_field(alias), "a canonical spelling other than the one reserved for type assertions", format!( "resolves to '{SCHEMA_TYPE_LABEL}', the relation label reserved for type \ @@ -577,7 +608,7 @@ mod tests { retracted_source: None, }, ); - let check = schema_issues(&env, &ops, ""); + let check = schema_issues(&env, &ops, IssuePath::Request { prefix: "" }); assert_eq!(check.violations.len(), 0, "off never judges"); assert_eq!( check.reserved.len(), @@ -603,7 +634,7 @@ mod tests { retracted_source: None, }, ); - let check = schema_issues(&env, &ops, ""); + let check = schema_issues(&env, &ops, IssuePath::Request { prefix: "" }); assert_eq!(check.reserved.len(), 1, "mode {mode:?}"); assert!( check.reserved[0].path.contains("型"), @@ -622,7 +653,7 @@ mod tests { let schema = installed(doc(SchemaMode::Strict, false)); let ops = [assoc_op("山田太郎", "杜氏", "鈴木一郎", 1.0, None)]; let env = env(&context, schema, &ops); - let check = schema_issues(&env, &ops, ""); + let check = schema_issues(&env, &ops, IssuePath::Request { prefix: "" }); assert_eq!( check.violations.len(), 1, @@ -636,6 +667,24 @@ mod tests { assert!(issue.actual.contains("Person"), "{}", issue.actual); } + /// [`IssuePath::Edge`]'s twin of the test above (#385, ADR 0009 §10's + /// schema audit): the path names only the side, never a request-body + /// coordinate — the audit already travels the offending association + /// alongside the issue, so there is no index for `path` to carry. + #[test] + fn edge_path_names_the_side_alone() { + let mut context = Context::default(); + context + .associate_from("山田太郎", SCHEMA_TYPE_LABEL, "Person", 1.0, "a.md", None) + .unwrap(); + let schema = installed(doc(SchemaMode::Strict, false)); + let ops = [assoc_op("山田太郎", "杜氏", "鈴木一郎", 1.0, None)]; + let env = env(&context, schema, &ops); + let check = schema_issues(&env, &ops, IssuePath::Edge); + assert_eq!(check.violations.len(), 1); + assert_eq!(check.violations[0].path, "subject"); + } + #[test] fn range_violation_is_reported_on_the_object_path() { let mut context = Context::default(); @@ -655,7 +704,7 @@ mod tests { let schema = installed(doc(SchemaMode::Strict, false)); let ops = [assoc_op("青嶺酒造", "杜氏", "醸造所", 1.0, None)]; let env = env(&context, schema, &ops); - let check = schema_issues(&env, &ops, ""); + let check = schema_issues(&env, &ops, IssuePath::Request { prefix: "" }); assert_eq!(check.violations.len(), 1); let issue = &check.violations[0]; assert_eq!(issue.kind, "range"); @@ -672,7 +721,7 @@ mod tests { .unwrap(); let ops = [assoc_op("青嶺酒造", "所在地", "広島", 1.0, None)]; let env = env(&context, schema, &ops); - let check = schema_issues(&env, &ops, ""); + let check = schema_issues(&env, &ops, IssuePath::Request { prefix: "" }); assert_eq!(check.violations.len(), 0); } @@ -682,7 +731,7 @@ mod tests { let schema = installed(doc(SchemaMode::Strict, false)); let ops = [assoc_op("青嶺酒造", "杜氏", "山田太郎", 1.0, None)]; let env = env(&context, schema, &ops); - let check = schema_issues(&env, &ops, ""); + let check = schema_issues(&env, &ops, IssuePath::Request { prefix: "" }); assert_eq!( check.violations.len(), 0, @@ -708,7 +757,7 @@ mod tests { let schema = installed(document); let ops = [assoc_op("青嶺酒造", "所属", "山田太郎", 1.0, None)]; let env = env(&context, schema, &ops); - let check = schema_issues(&env, &ops, ""); + let check = schema_issues(&env, &ops, IssuePath::Request { prefix: "" }); assert_eq!( check.violations.len(), 0, @@ -741,7 +790,7 @@ mod tests { let schema = installed(document); let ops = [assoc_op("青嶺酒造", "所属", "山田太郎", 1.0, None)]; let env = env(&context, schema, &ops); - let check = schema_issues(&env, &ops, ""); + let check = schema_issues(&env, &ops, IssuePath::Request { prefix: "" }); assert_eq!(check.violations.len(), 0); } @@ -762,7 +811,7 @@ mod tests { None, )]; let env = env(&context, schema, &ops); - let check = schema_issues(&env, &ops, ""); + let check = schema_issues(&env, &ops, IssuePath::Request { prefix: "" }); assert_eq!(check.violations.len(), 0); } @@ -775,7 +824,7 @@ mod tests { assoc_op("青嶺酒造", SCHEMA_TYPE_LABEL, "Brewery", 1.0, None), ]; let env = env(&context, schema, &ops); - let check = schema_issues(&env, &ops, ""); + let check = schema_issues(&env, &ops, IssuePath::Request { prefix: "" }); assert_eq!(check.violations.len(), 1, "{:?}", check.violations); assert_eq!(check.violations[0].kind, "unknown_reference"); assert_eq!(check.violations[0].path, "associations[0].label"); @@ -792,7 +841,7 @@ mod tests { // The op uses the alias spelling, not the canonical one. let ops = [assoc_op("青嶺", "杜氏", "山田太郎", 1.0, None)]; let env = env(&context, schema, &ops); - let check = schema_issues(&env, &ops, ""); + let check = schema_issues(&env, &ops, IssuePath::Request { prefix: "" }); assert_eq!( check.violations.len(), 1, @@ -829,7 +878,7 @@ mod tests { assoc_op("青嶺酒造", "所属", "山田太郎", 1.0, None), ]; let env = env(&context, schema, &ops); - let check = schema_issues(&env, &ops, ""); + let check = schema_issues(&env, &ops, IssuePath::Request { prefix: "" }); assert_eq!( check.violations.len(), 0, @@ -863,7 +912,7 @@ mod tests { retracted_source: Some("gone.md"), }, ); - let check = schema_issues(&env, &ops, ""); + let check = schema_issues(&env, &ops, IssuePath::Request { prefix: "" }); assert_eq!( check.violations.len(), 0, @@ -907,7 +956,7 @@ mod tests { retracted_source: Some("gone.md"), }, ); - let check = schema_issues(&env, &ops, ""); + let check = schema_issues(&env, &ops, IssuePath::Request { prefix: "" }); assert_eq!( check.violations.len(), 1, @@ -924,7 +973,7 @@ mod tests { let schema = installed(doc(SchemaMode::Strict, false)); let ops = [assoc_op("青嶺酒造", "杜氏", "山田太郎", 1.0, None)]; let env = env(&context, schema, &ops); - let check = schema_issues(&env, &ops, ""); + let check = schema_issues(&env, &ops, IssuePath::Request { prefix: "" }); assert_eq!(check.violations.len(), 1); } @@ -938,7 +987,7 @@ mod tests { let schema = installed(doc(SchemaMode::Strict, false)); let ops = [assoc_op("青嶺酒造", "杜氏", "山田太郎", 1.0, None)]; let env = env(&context, schema, &ops); - let check = schema_issues(&env, &ops, ""); + let check = schema_issues(&env, &ops, IssuePath::Request { prefix: "" }); assert_eq!( check.violations.len(), 0, @@ -955,7 +1004,7 @@ mod tests { let schema = installed(doc(SchemaMode::Strict, false)); let ops = [assoc_op("山田太郎", "杜氏", "鈴木一郎", -1.0, None)]; let env = env(&context, schema, &ops); - let check = schema_issues(&env, &ops, ""); + let check = schema_issues(&env, &ops, IssuePath::Request { prefix: "" }); assert_eq!( check.violations.len(), 1, @@ -974,7 +1023,7 @@ mod tests { .map(|i| assoc_op("山田太郎", "杜氏", &format!("弟子{i}"), 1.0, None)) .collect(); let env = env(&context, schema, &ops); - let check = schema_issues(&env, &ops, ""); + let check = schema_issues(&env, &ops, IssuePath::Request { prefix: "" }); assert_eq!( check.violations.len(), 25, @@ -991,7 +1040,13 @@ mod tests { let schema = installed(doc(SchemaMode::Strict, false)); let ops = [assoc_op("山田太郎", "杜氏", "鈴木一郎", 1.0, None)]; let env = env(&context, schema, &ops); - let check = schema_issues(&env, &ops, "batches[3]."); + let check = schema_issues( + &env, + &ops, + IssuePath::Request { + prefix: "batches[3].", + }, + ); assert_eq!( check.violations[0].path, "batches[3].associations[0].subject" @@ -1026,6 +1081,11 @@ mod tests { vec!["青嶺酒造"], "only the batch half's own type_op may populate `types`" ); - assert_eq!(schema_issues(&env, &ops, "").violations.len(), 0); + assert_eq!( + schema_issues(&env, &ops, IssuePath::Request { prefix: "" }) + .violations + .len(), + 0 + ); } } diff --git a/tests/http_api/key_scopes_cross_context.rs b/tests/http_api/key_scopes_cross_context.rs index 8dc43c02..ecbbb995 100644 --- a/tests/http_api/key_scopes_cross_context.rs +++ b/tests/http_api/key_scopes_cross_context.rs @@ -86,6 +86,23 @@ fn key_scopes_gate_roles_contexts_the_directory_and_mcp() { call("POST", "/contexts/sake/drift/audit", None, "rtok").0, 200 ); + // schema/validate (#385, ADR 0009 §12.5) is Role::Read too — it + // never persists anything, so a reader key reaches it directly. + // `sake` has no installed schema, which is exactly why `validate` + // (unlike `audit`) still answers 200 here. + assert_eq!( + call( + "POST", + "/contexts/sake/schema/validate", + Some(json!({"document": { + "schema": 1, "mode": "off", "closed_labels": false, + "types": {}, "relations": {} + }})), + "rtok" + ) + .0, + 200 + ); // #305's evidence assembly is Role::Read too — an unclassified // route would fail closed to Admin (auth.rs's own rule), so this // pins it reaches a scoped reader directly. @@ -159,6 +176,14 @@ fn key_scopes_gate_roles_contexts_the_directory_and_mcp() { call("POST", "/contexts/bunko/drift/audit", None, "wtok").0, 200 ); + // schema/audit (#385, ADR 0009 §12.5) is Role::Read too — `bunko` + // now has the schema just installed above, so the unscoped reader + // key reaches schema/audit directly, the same way it reaches + // drift/audit. + assert_eq!( + call("POST", "/contexts/bunko/schema/audit", None, "rtok").0, + 200 + ); assert_eq!(call("DELETE", "/contexts/bunko", None, "wtok").0, 403); assert_eq!(call("POST", "/flush", None, "wtok").0, 403); diff --git a/tests/http_api/main.rs b/tests/http_api/main.rs index 1b36a2ea..6c762976 100644 --- a/tests/http_api/main.rs +++ b/tests/http_api/main.rs @@ -43,6 +43,7 @@ mod retrieval_cache; mod retrieval_core; mod routing; mod schema; +mod schema_audit; mod schema_export_import; mod schema_import; mod schema_type_label; diff --git a/tests/http_api/mcp_basics.rs b/tests/http_api/mcp_basics.rs index 9e3f39aa..b41f3e12 100644 --- a/tests/http_api/mcp_basics.rs +++ b/tests/http_api/mcp_basics.rs @@ -445,6 +445,50 @@ fn mcp_get_and_put_schema_round_trip_through_the_http_route() { assert_eq!(body["result"], document, "{text}"); } +/// #385 (S7, ADR 0009 §10): `audit_schema`/`validate_schema` dispatch +/// onto `POST .../schema/audit` and `.../schema/validate` exactly like +/// every other MCP tool routes onto its HTTP twin. +#[test] +fn mcp_audit_and_validate_schema_round_trip_through_the_http_route() { + let server = Server::start("mcp-schema-audit-tools"); + server.ok("PUT", "/contexts/sake", Some(json!({}))); + server.ok( + "POST", + "/contexts/sake/associations", + Some(json!([ + {"subject": "高瀬", "label": "schema:type", "object": "Person", + "weight": 1.0, "source": "a.md"}, + {"subject": "高瀬", "label": "杜氏", "object": "個人A", + "weight": 1.0, "source": "a.md"}, + ])), + ); + + let document = json!({ + "schema": 1, + "mode": "strict", + "closed_labels": false, + "types": {"Brewery": {}, "Person": {}}, + "relations": {"杜氏": {"domain": ["Brewery"], "range": ["Person"]}} + }); + + let validate_reply = server.call_tool( + 1, + "validate_schema", + json!({"context": "sake", "document": document}), + ); + assert!(validate_reply.get("isError").is_none(), "{validate_reply}"); + let text = validate_reply["content"][0]["text"].as_str().unwrap(); + let body: Value = serde_json::from_str(text).unwrap(); + assert_eq!(body["result"]["total"], json!(1), "{text}"); + + server.ok("PUT", "/contexts/sake/schema", Some(document)); + let audit_reply = server.call_tool(2, "audit_schema", json!({"context": "sake"})); + assert!(audit_reply.get("isError").is_none(), "{audit_reply}"); + let text = audit_reply["content"][0]["text"].as_str().unwrap(); + let body: Value = serde_json::from_str(text).unwrap(); + assert_eq!(body["result"]["total"], json!(1), "{text}"); +} + /// issue #182: the MCP `import` tool reports the same durable-prefix /// integrity a raw `POST /import` refusal does, over the Streamable /// HTTP transport. diff --git a/tests/http_api/replication.rs b/tests/http_api/replication.rs index a1a91947..e8042e31 100644 --- a/tests/http_api/replication.rs +++ b/tests/http_api/replication.rs @@ -692,6 +692,20 @@ fn a_replica_serves_reads_tails_the_writer_and_refuses_writes() { json!({"醸造所": {"is_a": []}}), "{sake_schema}" ); + // `schema/audit` and `schema/validate` (#385, ADR 0009 §12.5) are + // Role::Read too — neither writes anything, so both pass the + // replica gate exactly like `GET /schema` above. + let audit = replica.ok("POST", "/contexts/sake/schema/audit", None); + assert_eq!(audit["total"], json!(0), "{audit}"); + let validated = replica.ok( + "POST", + "/contexts/sake/schema/validate", + Some(json!({"document": { + "schema": 1, "mode": "strict", "closed_labels": false, + "types": {}, "relations": {} + }})), + ); + assert_eq!(validated["total"], json!(0), "{validated}"); // The scrape carries the replica shape from boot; the generation // gauge lands with the tailer's first poll. diff --git a/tests/http_api/schema_audit.rs b/tests/http_api/schema_audit.rs new file mode 100644 index 00000000..dcbabde9 --- /dev/null +++ b/tests/http_api/schema_audit.rs @@ -0,0 +1,470 @@ +//! `POST /contexts/{name}/schema/audit` and `POST +//! /contexts/{name}/schema/validate` (#385, S7 of #218's ADR 0009 split +//! §10): the standing, read-only audit over the live graph and the +//! never-persisted dry-run of a proposed document. Both share +//! [`crate::api::schema::schema_audit`]'s judgment with `strict` itself +//! (S3, #381's `schema_issues`), so a finding here is exactly what a +//! `strict` write would refuse for the same fact — this file pins that +//! contract, not `GET`/`PUT /schema`'s own round trip (`schema.rs`). + +use serde_json::json; + +use crate::support::*; + +fn strict_document() -> serde_json::Value { + json!({ + "schema": 1, + "mode": "strict", + "closed_labels": false, + "types": { + "Brewery": {"is_a": ["Organization"]}, + "Organization": {"is_a": []}, + "Person": {"is_a": []} + }, + "relations": { + "杜氏": {"domain": ["Brewery"], "range": ["Person"]} + } + }) +} + +/// `audit`'s 404s mirror `GET /schema`'s own distinction (ADR 0009 +/// §6.3): no context at all vs. a context that simply never installed a +/// schema. +#[test] +fn audit_refuses_with_no_schema_or_no_context() { + let server = Server::start("schema-audit-404"); + server.ok("PUT", "/contexts/sake", Some(json!({"description": "d"}))); + + let (status, body) = server.call("POST", "/contexts/sake/schema/audit", None); + assert_eq!(status, 404, "{body}"); + assert_eq!(body["code"], "no_schema", "{body}"); + + let (status, body) = server.call("POST", "/contexts/nope/schema/audit", None); + assert_eq!(status, 404, "{body}"); + assert_eq!(body["code"], "no_context", "{body}"); +} + +/// ADR 0009 §7.1: "pre-existing violations are visible only through the +/// explicitly-invoked, read-only audit" — the audit must judge by the +/// same domain/range predicate `strict` would, even when the document's +/// actual `mode` is `off`. This is the one behavior that could not be +/// pinned by reusing `schema_issues`' own unit tests: those all pass +/// `mode` in directly. +#[test] +fn audit_reports_domain_violations_even_in_off_mode() { + let server = Server::start("schema-audit-off-mode"); + server.ok("PUT", "/contexts/sake", Some(json!({"description": "d"}))); + server.ok( + "POST", + "/contexts/sake/associations", + Some(json!([ + {"subject": "高瀬", "label": "schema:type", "object": "Person", + "weight": 1.0, "source": "a.md"}, + {"subject": "高瀬", "label": "杜氏", "object": "個人A", + "weight": 1.0, "source": "a.md"}, + ])), + ); + let mut document = strict_document(); + document["mode"] = json!("off"); + server.ok("PUT", "/contexts/sake/schema", Some(document)); + + let audit = server.ok("POST", "/contexts/sake/schema/audit", None); + assert_eq!(audit["total"], json!(1), "{audit}"); + let violations = audit["violations"].as_array().unwrap(); + assert_eq!(violations.len(), 1, "{audit}"); + let violation = &violations[0]; + assert_eq!( + violation["association"]["subject"], + json!("高瀬"), + "{audit}" + ); + assert_eq!(violation["association"]["label"], json!("杜氏"), "{audit}"); + let issues = violation["issues"].as_array().unwrap(); + assert_eq!(issues.len(), 1, "{audit}"); + assert_eq!(issues[0]["kind"], json!("domain"), "{audit}"); + // IssuePath::Edge names only the side, never a request-body index — + // the associated edge already travels alongside the issue. + assert_eq!(issues[0]["path"], json!("subject"), "{audit}"); + assert!(issues[0]["actual"].as_str().unwrap().contains("Person")); +} + +/// `warn` and `strict` must answer identically to `off` — the whole +/// point of §7.1's framing is that the audit's answer never depends on +/// the document's own current mode. +#[test] +fn audit_answers_the_same_regardless_of_mode() { + let server = Server::start("schema-audit-mode-invariant"); + server.ok("PUT", "/contexts/sake", Some(json!({"description": "d"}))); + server.ok( + "POST", + "/contexts/sake/associations", + Some(json!([ + {"subject": "高瀬", "label": "schema:type", "object": "Person", + "weight": 1.0, "source": "a.md"}, + {"subject": "高瀬", "label": "杜氏", "object": "個人A", + "weight": 1.0, "source": "a.md"}, + ])), + ); + + let mut audits = Vec::new(); + for mode in ["off", "warn", "strict"] { + let mut document = strict_document(); + document["mode"] = json!(mode); + server.ok("PUT", "/contexts/sake/schema", Some(document)); + audits.push(server.ok("POST", "/contexts/sake/schema/audit", None)); + } + // The whole response, not just `total` — every section (violations' + // issue detail included) must be identical across modes, not merely + // the same count. + assert_eq!(audits[0], audits[1], "off vs warn: {audits:?}"); + assert_eq!(audits[1], audits[2], "warn vs strict: {audits:?}"); +} + +/// `untyped_concepts` excludes concepts that are themselves asserted +/// type names (§6.3 exclusion 3's own reasoning) and concepts already +/// declared in `types`, but still names an ordinary fact concept that +/// never received a `schema:type` assertion. +#[test] +fn audit_untyped_concepts_excludes_type_names_and_declared_types() { + let server = Server::start("schema-audit-untyped"); + server.ok("PUT", "/contexts/sake", Some(json!({"description": "d"}))); + server.ok( + "POST", + "/contexts/sake/associations", + Some(json!([ + {"subject": "青嶺酒造", "label": "schema:type", "object": "Brewery", + "weight": 1.0, "source": "a.md"}, + // "個人A" carries no schema:type assertion of its own. + {"subject": "青嶺酒造", "label": "所在地", "object": "個人A", + "weight": 1.0, "source": "a.md"}, + ])), + ); + server.ok("PUT", "/contexts/sake/schema", Some(strict_document())); + + let audit = server.ok("POST", "/contexts/sake/schema/audit", None); + let names: Vec<&str> = audit["untyped_concepts"]["names"] + .as_array() + .unwrap() + .iter() + .map(|v| v.as_str().unwrap()) + .collect(); + assert!(names.contains(&"個人A"), "{audit}"); + assert!( + !names.contains(&"青嶺酒造"), + "typed via its own schema:type assertion: {audit}" + ); + assert!( + !names.contains(&"Brewery"), + "a type name itself, excluded per §6.3 exclusion 3: {audit}" + ); + assert!( + !names.contains(&"Organization") && !names.contains(&"Person"), + "declared types with no live use are not \"untyped concepts\": {audit}" + ); +} + +/// §6.2: a type name asserted but absent from `types` is always +/// reported, unconditional on `closed_labels`. +#[test] +fn audit_undeclared_types_reported_regardless_of_closed_labels() { + let server = Server::start("schema-audit-undeclared-types"); + server.ok("PUT", "/contexts/sake", Some(json!({"description": "d"}))); + server.ok( + "POST", + "/contexts/sake/associations", + Some(json!([ + {"subject": "青嶺酒造", "label": "schema:type", "object": "Distillery", + "weight": 1.0, "source": "a.md"}, + ])), + ); + for closed_labels in [false, true] { + let mut document = strict_document(); + document["closed_labels"] = json!(closed_labels); + server.ok("PUT", "/contexts/sake/schema", Some(document)); + let audit = server.ok("POST", "/contexts/sake/schema/audit", None); + assert_eq!( + audit["undeclared_types"]["names"], + json!(["Distillery"]), + "closed_labels={closed_labels}: {audit}" + ); + } +} + +/// §6.4: `unknown_labels` stays empty unless `closed_labels` is set, and +/// even then never names `schema:type` itself. +#[test] +fn audit_unknown_labels_only_when_closed_labels_and_never_schema_type() { + let server = Server::start("schema-audit-unknown-labels"); + server.ok("PUT", "/contexts/sake", Some(json!({"description": "d"}))); + server.ok( + "POST", + "/contexts/sake/associations", + Some(json!([ + {"subject": "青嶺酒造", "label": "schema:type", "object": "Brewery", + "weight": 1.0, "source": "a.md"}, + {"subject": "青嶺酒造", "label": "所在地", "object": "広島", + "weight": 1.0, "source": "a.md"}, + ])), + ); + + server.ok("PUT", "/contexts/sake/schema", Some(strict_document())); + let open = server.ok("POST", "/contexts/sake/schema/audit", None); + assert_eq!(open["unknown_labels"]["names"], json!([]), "{open}"); + + let mut closed = strict_document(); + closed["closed_labels"] = json!(true); + server.ok("PUT", "/contexts/sake/schema", Some(closed)); + let audit = server.ok("POST", "/contexts/sake/schema/audit", None); + let names: Vec<&str> = audit["unknown_labels"]["names"] + .as_array() + .unwrap() + .iter() + .map(|v| v.as_str().unwrap()) + .collect(); + assert_eq!(names, vec!["所在地"], "{audit}"); + assert!( + !names.contains(&"schema:type"), + "schema:type never counts as an unknown label, even under closed_labels: {audit}" + ); + // The same fact also drives a per-edge `violations` entry — the two + // sections answer different questions, so both fire together. + let violations = audit["violations"].as_array().unwrap(); + assert!( + violations + .iter() + .any(|v| v["association"]["label"] == json!("所在地") + && v["issues"] + .as_array() + .unwrap() + .iter() + .any(|issue| issue["kind"] == json!("unknown_reference"))), + "{audit}" + ); +} + +/// `violations` pages the same way `drift/audit`'s `unsourced` does: +/// `limit`/`after` resume in the same worst-magnitude-first order, and +/// `total` stays constant across pages. +#[test] +fn audit_violations_page_like_every_other_match_list() { + let server = Server::start("schema-audit-paging"); + server.ok("PUT", "/contexts/sake", Some(json!({"description": "d"}))); + server.ok( + "POST", + "/contexts/sake/associations", + Some(json!([ + {"subject": "高瀬", "label": "schema:type", "object": "Person", + "weight": 1.0, "source": "a.md"}, + {"subject": "高瀬", "label": "杜氏", "object": "弟子1", + "weight": 3.0, "source": "a.md"}, + {"subject": "高瀬", "label": "杜氏", "object": "弟子2", + "weight": 2.0, "source": "a.md"}, + {"subject": "高瀬", "label": "杜氏", "object": "弟子3", + "weight": 1.0, "source": "a.md"}, + ])), + ); + server.ok("PUT", "/contexts/sake/schema", Some(strict_document())); + + let full = server.ok("POST", "/contexts/sake/schema/audit", None); + assert_eq!(full["total"], json!(3), "{full}"); + let full_matches = full["violations"].as_array().unwrap(); + // Worst-magnitude-first, exactly like `drift/audit`'s `unsourced`. + assert_eq!( + full_matches + .iter() + .map(|v| v["association"]["object"].as_str().unwrap()) + .collect::>(), + vec!["弟子1", "弟子2", "弟子3"], + "{full}" + ); + + let first = server.ok( + "POST", + "/contexts/sake/schema/audit", + Some(json!({"limit": 2})), + ); + assert_eq!(first["total"], json!(3), "{first}"); + let first_matches = first["violations"].as_array().unwrap(); + assert_eq!( + first_matches + .iter() + .map(|v| v["association"]["object"].as_str().unwrap()) + .collect::>(), + vec!["弟子1", "弟子2"], + "{first}" + ); + let last = &first_matches[1]["association"]; + let cursor = json!({ + "weight": last["weight"], "subject": last["subject"], + "label": last["label"], "object": last["object"], + }); + let second = server.ok( + "POST", + "/contexts/sake/schema/audit", + Some(json!({"limit": 2, "after": cursor})), + ); + assert_eq!(second["total"], json!(3), "{second}"); + let second_matches = second["violations"].as_array().unwrap(); + // Resumes exactly where the first page stopped — 弟子3 alone, never + // 弟子1/弟子2 again — and the two pages together reconstruct the + // whole unpaginated order with no gap or duplicate. + assert_eq!( + second_matches + .iter() + .map(|v| v["association"]["object"].as_str().unwrap()) + .collect::>(), + vec!["弟子3"], + "{second}" + ); + assert_eq!(second_matches[0], full_matches[2], "{second} vs {full}"); +} + +/// §6.3 guard 2's migration-boundary bullet refuses `PUT /schema` +/// outright when a pre-existing alias already resolves to the reserved +/// label (`schema.rs`'s own +/// `a_label_alias_resolving_to_the_reserved_type_label_refuses_the_put`) +/// — so a RESIDENT schema can never carry this conflict, only a +/// *proposed* one can. `validate` is exactly where an operator would +/// discover it: before ever attempting the `PUT` that would otherwise +/// just 400 on the alias alone, with none of the other findings this +/// route surfaces alongside it. +#[test] +fn validate_surfaces_a_pre_existing_reserved_alias_conflict() { + let server = Server::start("schema-validate-reserved-alias"); + server.ok("PUT", "/contexts/sake", Some(json!({"description": "d"}))); + // Legal today — guard 1: `schema:type` is an ordinary label until a + // schema exists — and interns the label id the alias resolves + // against. + server.ok( + "POST", + "/contexts/sake/associations", + Some(json!([ + {"subject": "蔵", "label": "schema:type", "object": "Brewery", + "weight": 1.0, "source": "a.md"}, + ])), + ); + server.ok( + "POST", + "/contexts/sake/aliases", + Some(json!({"labels": {"種類": "schema:type"}})), + ); + + let audit = server.ok( + "POST", + "/contexts/sake/schema/validate", + Some(json!({"document": strict_document()})), + ); + assert_eq!( + audit["reserved_alias_conflicts"], + json!({"total": 1, "aliases": {"種類": "schema:type"}}), + "{audit}" + ); + + // Confirms the scenario this section exists to warn about: the same + // document really does refuse at `PUT` time, naming the same alias. + let (status, body) = server.call("PUT", "/contexts/sake/schema", Some(strict_document())); + assert_eq!(status, 400, "{body}"); + assert!(body["error"].as_str().unwrap().contains("種類"), "{body}"); +} + +/// `validate` judges a PROPOSED document the same way `audit` judges +/// the resident one, but never persists it — `GET /schema` still 404s +/// afterward. +#[test] +fn validate_dry_runs_without_persisting() { + let server = Server::start("schema-validate-dry-run"); + server.ok("PUT", "/contexts/sake", Some(json!({"description": "d"}))); + server.ok( + "POST", + "/contexts/sake/associations", + Some(json!([ + {"subject": "高瀬", "label": "schema:type", "object": "Person", + "weight": 1.0, "source": "a.md"}, + {"subject": "高瀬", "label": "杜氏", "object": "個人A", + "weight": 1.0, "source": "a.md"}, + ])), + ); + + let audit = server.ok( + "POST", + "/contexts/sake/schema/validate", + Some(json!({"document": strict_document()})), + ); + assert_eq!(audit["total"], json!(1), "{audit}"); + let violations = audit["violations"].as_array().unwrap(); + assert_eq!( + violations[0]["issues"][0]["kind"], + json!("domain"), + "{audit}" + ); + + let (status, body) = server.call("GET", "/contexts/sake/schema", None); + assert_eq!(status, 404, "validate must never persist: {body}"); + assert_eq!(body["code"], "no_schema", "{body}"); +} + +/// `validate` works over a schema-free context — the primary dry-run +/// use case, ADR 0009 §7.1's pre-flight before a `strict` flip — and +/// answers identically whether the context already has an installed +/// schema or none at all. +#[test] +fn validate_works_whether_or_not_a_schema_is_already_installed() { + let server = Server::start("schema-validate-either-way"); + server.ok("PUT", "/contexts/sake", Some(json!({"description": "d"}))); + server.ok( + "POST", + "/contexts/sake/associations", + Some(json!([ + {"subject": "高瀬", "label": "schema:type", "object": "Person", + "weight": 1.0, "source": "a.md"}, + {"subject": "高瀬", "label": "杜氏", "object": "個人A", + "weight": 1.0, "source": "a.md"}, + ])), + ); + + let without = server.ok( + "POST", + "/contexts/sake/schema/validate", + Some(json!({"document": strict_document()})), + ); + + // Install an unrelated, non-violating schema, then re-run the same + // proposed document — the resident schema must play no part. + server.ok( + "PUT", + "/contexts/sake/schema", + Some(json!({ + "schema": 1, "mode": "off", "closed_labels": false, + "types": {}, "relations": {} + })), + ); + let with = server.ok( + "POST", + "/contexts/sake/schema/validate", + Some(json!({"document": strict_document()})), + ); + // The whole response, not just `total`/`violations` — every section + // must agree, since the resident schema plays no part in `validate` + // at all. + assert_eq!(without, with, "{without} vs {with}"); +} + +/// A malformed proposed document (here: a relation named the reserved +/// `schema:type` label, ADR 0009 §6.3 guard 3) refuses the same way +/// `PUT /schema` itself would — 400 `invalid_argument`, never a 500 or a +/// silently-accepted document. +#[test] +fn validate_refuses_an_invalid_proposed_document() { + let server = Server::start("schema-validate-invalid-document"); + server.ok("PUT", "/contexts/sake", Some(json!({"description": "d"}))); + + let mut document = strict_document(); + document["relations"]["schema:type"] = json!({"domain": [], "range": []}); + let (status, body) = server.call( + "POST", + "/contexts/sake/schema/validate", + Some(json!({"document": document})), + ); + assert_eq!(status, 400, "{body}"); + assert_eq!(body["code"], "invalid_argument", "{body}"); +}