diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json index 34879f2e..5482433c 100644 --- a/.agents/plugins/marketplace.json +++ b/.agents/plugins/marketplace.json @@ -13,7 +13,7 @@ "name": "graqle", "source": "./plugins/codex/graqle", "description": "Graph-powered codebase reasoning, impact analysis, and governed edits via the GraQle MCP server, plus governed-workflow skills.", - "version": "0.84.0", + "version": "0.84.1", "author": { "name": "Quantamix Solutions", "url": "https://graqle.com" diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 09b086bf..8ccaa7e5 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -13,7 +13,7 @@ "name": "graqle", "source": "./plugins/claude-code/graqle", "description": "Graph-powered codebase reasoning, impact analysis, and governed edits via the GraQle MCP server, plus governed-workflow skills and an optional governance gate hook.", - "version": "0.84.0", + "version": "0.84.1", "author": { "name": "Quantamix Solutions", "url": "https://graqle.com" diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..edffb1f7 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,6 @@ +# CR-012 PR-012c: the AC-9 golden fixture is byte-hash-asserted by +# tests/test_core/test_governance_threshold_resolver.py. With core.autocrlf=true +# a Windows checkout rewrites it to CRLF and the recorded sha256 no longer +# matches, so the test fails on a fresh clone for no behavioural reason. +# Pin it to LF everywhere. +tests/fixtures/*.json -text eol=lf diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 95043618..6e98aa44 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,6 +32,13 @@ jobs: ruff check --fix graqle/ tests/ || true echo "Lint complete (warnings reported, not blocking)" + # CR-012 PR-012b (AC-21): the DAG dependency-direction contract. + # `graqle.assurance` may import `graqle.governance`; never the reverse. + # BLOCKING on purpose — a violation here is the failure the duplicated + # `GateVerdictRef` shape exists to prevent. + - name: Import-linter contracts + run: lint-imports + - name: Run tests with coverage run: | # 26 test files reference classes from stub files emptied in a prior diff --git a/.importlinter b/.importlinter new file mode 100644 index 00000000..451fd588 --- /dev/null +++ b/.importlinter @@ -0,0 +1,29 @@ +# CR-012 AC-21 — dependency direction for the DAG-2026 assurance layer. +# +# `graqle.assurance` may import `graqle.governance`; the reverse is forbidden. +# This is why `GateVerdictRef` is duplicated in `governance/trace_schema.py` +# with a str-typed `outcome` instead of importing the `GateOutcome` enum +# (CR-012 §4.4, OQ-3). A parity test keeps the two shapes field-identical. +# +# Run locally: lint-imports +[importlinter] +root_package = graqle + +[importlinter:contract:governance-not-import-assurance] +name = graqle.governance must never import graqle.assurance +type = forbidden +source_modules = + graqle.governance +forbidden_modules = + graqle.assurance +# Ruling N5: `config/settings.py` reaches `graqle.assurance` ONLY from inside +# function/property bodies (settings.py:812 AssuranceConfig.enabled, +# :1188 _reject_yaml_derived, :1202 _validate_dag_flag), and +# `governance.kg_write_gate` imports `config.settings` only under +# TYPE_CHECKING. import-linter's static graph counts those lazy edges, but at +# RUNTIME importing graqle.governance loads no graqle.assurance module — which +# is the invariant N5 actually specifies and what the CR's import-graph tests +# assert. `tests/test_assurance/test_import_direction.py` proves it at runtime; +# this contract catches any EAGER, module-level import. +ignore_imports = + graqle.config.settings -> graqle.assurance.settings diff --git a/CHANGELOG.md b/CHANGELOG.md index edab620b..4fd85522 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,15 @@ All notable changes to GraQle are documented in this file. --- -## Unreleased — 0.84.0 (DAG-2026 CR-012 foundation) +## Unreleased — 0.84.1 (DAG-2026 CR-012 foundation) + +> **CI suppression disclosure (CR-CI-001, issue #350).** CI's green signal for this release is +> maintained by **37 `--ignore=` flags** in `.github/workflows/ci.yml`: those test files are not +> executed by CI. **16 test files fail on a local developer machine**, all pre-existing and +> predating the DAG-2026 programme. This release introduces **zero** regressions — the failure +> set is identical on this branch and on the unmodified base. Remediation is tracked as +> **CR-CI-001 (issue #350)**, which **blocks v0.85.0**. One defect that the suppression had +> hidden — `_FakeServer` doubles broken by PR-012c — is fixed in this release. > Feature-flagged foundation for the Decision Assurance Gate programme (ADR-RT-004). > `GRAQLE_DAG_ENABLED` is **off by default** and, while off, changes no SDK behaviour: @@ -24,6 +32,35 @@ All notable changes to GraQle are documented in this file. - **`docs/dag/ground-truth-addendum.md`** — the binding correction of the research charter's description of 0.83.0 gating, with the SDK team's line-anchor verification notes. +### Added (CR-012 / PR-012b — gate vocabulary) + +- **`graqle.assurance.outcomes`** — `GateOutcome`, the five outcomes a gate evaluation can return, + with `OUTCOME_SEVERITY` ordering and `worst()` / `worst_terminal()` reducers. `GateOutcome` is + private to `graqle.assurance` and is deliberately *not* an extension of + `graqle.governance.trace_schema.Decision`: `Decision` records what a governance gate did, + `GateOutcome` records what the caller must do next. +- **`graqle.assurance.reason_codes`** — a validated registry of machine-readable reason codes + (`DAG--`), `Severity`, `validate()`, `max_severity()` and `resolvable()`. + Codes are registered through the registry, never matched ad hoc by a local regex. +- **`graqle.assurance.verdict`** — `GateVerdict`, `GateVerdictRef`, `HardGateResultRef` and + `DeterminismRecord`, at `VERDICT_SCHEMA_VERSION = "1"`. +- **Trace schema v3** — the trace carries the gate outcome as a plain string via `GateVerdictRef`, + preserving the rule that `graqle.governance` never imports `graqle.assurance` (CR-012 AC-21), + which is enforced by a contract test in CI. + +### Added (CR-012 / PR-012d — fail-visible activation) + +- **Typed activation failures** — `VectorIndexMissingError` and `EmbeddingDimensionMismatchError` + replace silent degradation when a Neo4j vector index is absent or an embedding width disagrees. + Activation now fails visibly instead of quietly returning weaker results. +- **`graq doctor`** reports Neo4j activation readiness, so the condition is diagnosable before a run. + +### Changed (CR-012 / PR-012c — hygiene) + +- **`graqle/core/governance_thresholds.py`** centralises governance threshold resolution behind + `resolve_governance_config()`, replacing scattered per-call-site defaults. +- Reliability-diagram and Neo4j connector fixes; MCP dev-server tool registration tidied. + ### Behaviour change when the flag is ON (opt-in only) - `GraqleConfig.from_yaml()` now fails closed with `ConfigurationError` if `GRAQLE_DAG_ENABLED` is on and diff --git a/graqle/__version__.py b/graqle/__version__.py index ee231b07..cf56fa74 100644 --- a/graqle/__version__.py +++ b/graqle/__version__.py @@ -5,4 +5,4 @@ # constraints: none # ── /graqle:intelligence ── -__version__ = "0.84.0" +__version__ = "0.84.1" diff --git a/graqle/activation/cypher_activation.py b/graqle/activation/cypher_activation.py index 067da76d..d3ff29a1 100644 --- a/graqle/activation/cypher_activation.py +++ b/graqle/activation/cypher_activation.py @@ -30,10 +30,16 @@ from __future__ import annotations import logging +import time from typing import Any logger = logging.getLogger("graqle.activation.cypher") +#: TTL for the diagnostic vector-index probe (sentinel M1). Short enough that +#: an operator fixing the index sees the change almost immediately; long enough +#: that a retry storm cannot amplify load on an already-failing substrate. +_PROBE_TTL_SECONDS = 30.0 + class CypherActivation: """Activate subgraph nodes via Neo4j chunk-level vector search. @@ -56,6 +62,7 @@ def __init__( embedding_engine: Any, max_nodes: int = 50, k_chunks: int = 100, + strict: bool = False, ) -> None: """ Args: @@ -64,50 +71,153 @@ def __init__( max_nodes: Maximum number of nodes to activate. k_chunks: Number of chunks to retrieve from vector index (more chunks means better coverage but slower). + strict: CR-012 PR-012d (ruling N7). When True, a substrate that + cannot activate raises + :class:`~graqle.core.exceptions.VectorIndexMissingError` instead + of degrading to the full graph. Default False preserves + v0.83.0 behaviour except that the degraded path now logs at + ERROR and is reported via :attr:`activation_mode`. """ self._connector = connector self._embedding_engine = embedding_engine self._max_nodes = max_nodes self._k_chunks = k_chunks + self._strict = strict self.last_relevance: dict[str, float] = {} + #: What actually executed on the last ``activate`` call — NOT what was + #: configured. ``"semantic"`` is the healthy vector path; + #: ``"keyword_fallback"`` means the whole graph was returned because + #: activation could not run. Read by the graph-health probe, which + #: treats ``keyword_fallback`` as degraded. + self.activation_mode: str = "semantic" + #: Chunks with no embedding at the last degradation, when known. + self.chunks_unembedded: int = 0 + #: Cached ``probe_vector_index()`` result + monotonic timestamp. + self._probe_cache: tuple[float, dict[str, Any]] | None = None + + def _probe_cached(self) -> dict[str, Any]: + """Diagnostic probe with a short TTL. + + Sentinel M1: ``_degrade`` runs on a path that is ALREADY failing. Probing + the substrate on every call amplifies load on the degraded resource at + the worst possible moment — a retry loop or concurrent burst would issue + one live round-trip per failure. The result is diagnostic metadata, so a + few seconds of staleness costs nothing. + + Never raises: diagnosis must not become a second failure mode. + """ + now = time.monotonic() + if self._probe_cache is not None and (now - self._probe_cache[0]) < _PROBE_TTL_SECONDS: + return self._probe_cache[1] + + probe: dict[str, Any] = {} + try: + probe_fn = getattr(self._connector, "probe_vector_index", None) + if callable(probe_fn): + probe = probe_fn() or {} + except Exception: # noqa: BLE001 — diagnosis must never mask the defect + probe = {} + + self._probe_cache = (now, probe) + return probe + + def _degrade(self, graph: Any, reason: str, exc: Exception | None = None) -> list[str]: + """Fall back to the full graph, loudly. + + Every degraded activation logs at ERROR and is visible in + ``graph_health`` — the silent-WARNING path this replaces is the defect + ruling N7 was raised against. When ``strict`` is set the caller gets a + typed error instead of a quietly wrong answer. + """ + probe = self._probe_cached() + + total = probe.get("chunks_total") + embedded = probe.get("chunks_embedded") + self.chunks_unembedded = ( + max(0, int(total) - int(embedded)) + if isinstance(total, int) and isinstance(embedded, int) + else 0 + ) + self.activation_mode = "keyword_fallback" + + # Sentinel B1: log LEVEL is an observable contract that alerting keys + # on. The default (non-strict) path keeps v0.83.0's WARNING so a + # graceful fallback does not start paging operators; only an opt-in + # strict caller — who has asked to treat this as fatal — gets ERROR. + # The enriched CONTENT (index state + coverage) lands on both paths, + # which is the "log fields" improvement ruling N7 asked for. + logger.log( + logging.ERROR if self._strict else logging.WARNING, + "CypherActivation DEGRADED (%s): returning the full graph, so this " + "result is NOT retrieval-grounded. vector index %r state=%s " + "chunk embedding coverage=%s/%s%s", + reason, + probe.get("index_name", "?"), + probe.get("index_state", "?"), + embedded if embedded is not None else "?", + total if total is not None else "?", + f" ({type(exc).__name__}: {exc})" if exc is not None else "", + ) + + if self._strict: + from graqle.core.exceptions import VectorIndexMissingError + + raise VectorIndexMissingError( + index_name=str(probe.get("index_name", "unknown")), + database=probe.get("database"), + index_state=probe.get("index_state"), + chunks_total=total, + chunks_embedded=embedded, + ) from exc + + self.last_relevance = {nid: 1.0 for nid in graph.nodes} + return list(graph.nodes.keys())[:self._max_nodes] def activate( self, graph: Any, query: str, + strict: bool | None = None, ) -> list[str]: """Activate nodes by vector search on chunk embeddings. Side effect: stores relevance scores in ``self.last_relevance`` - for use in confidence calibration (Bug 18 fix). + for use in confidence calibration (Bug 18 fix), and records what + actually ran in ``self.activation_mode``. + + Args: + strict: per-call override of the constructor's ``strict``. When + True, an unusable substrate raises ``VectorIndexMissingError``. Returns: List of activated node IDs present in the graph. """ - # 1. Embed the query - try: - query_embedding = self._embedding_engine.embed(query) - except Exception as exc: - logger.warning("CypherActivation: embedding failed (%s), falling back to full", exc) - self.last_relevance = {nid: 1.0 for nid in graph.nodes} - return list(graph.nodes.keys())[:self._max_nodes] - - # 2. Vector search → (node_id, relevance) pairs + previous_strict = self._strict + if strict is not None: + self._strict = strict try: - hits = self._connector.vector_search( - query_embedding=query_embedding, - k=self._k_chunks, - max_nodes=self._max_nodes, - ) - except Exception as exc: - logger.warning("CypherActivation: vector search failed (%s), falling back to full", exc) - self.last_relevance = {nid: 1.0 for nid in graph.nodes} - return list(graph.nodes.keys())[:self._max_nodes] - - if not hits: - logger.warning("CypherActivation: vector search returned 0 hits, using full graph") - self.last_relevance = {nid: 1.0 for nid in graph.nodes} - return list(graph.nodes.keys())[:self._max_nodes] + # 1. Embed the query + try: + query_embedding = self._embedding_engine.embed(query) + except Exception as exc: + return self._degrade(graph, "query embedding failed", exc) + + # 2. Vector search → (node_id, relevance) pairs + try: + hits = self._connector.vector_search( + query_embedding=query_embedding, + k=self._k_chunks, + max_nodes=self._max_nodes, + ) + except Exception as exc: + return self._degrade(graph, "vector search failed", exc) + + if not hits: + return self._degrade(graph, "vector search returned 0 hits") + finally: + self._strict = previous_strict + + self.activation_mode = "semantic" # 3. Filter to nodes that exist in the in-memory graph activated = [] diff --git a/graqle/activation/factory_helpers.py b/graqle/activation/factory_helpers.py index 61a4be3b..2f66cb91 100644 --- a/graqle/activation/factory_helpers.py +++ b/graqle/activation/factory_helpers.py @@ -173,10 +173,16 @@ def _make_cypher_activation(graph: Any) -> Any: ) emb_engine = None + # CR-012 PR-012d (ruling N7): `strict` is plumbed from config so the kwarg + # is reachable in production, not just from tests. Absent config keeps the + # v0.83.0 default (False) — degrade, but loudly and visibly. + strict = bool(getattr(cfg.activation, "strict", False)) + return CypherActivation( connector=graph._neo4j_connector, embedding_engine=emb_engine, max_nodes=cfg.activation.max_nodes, + strict=strict, ) diff --git a/graqle/activation/multi_signal.py b/graqle/activation/multi_signal.py index efa46725..92a26623 100644 --- a/graqle/activation/multi_signal.py +++ b/graqle/activation/multi_signal.py @@ -40,10 +40,16 @@ from __future__ import annotations import logging +import time from typing import Any logger = logging.getLogger("graqle.activation.multi_signal") +#: TTL for the diagnostic vector-index probe (sentinel M1). Short enough that +#: an operator fixing the index sees the change almost immediately; long enough +#: that a retry storm cannot amplify load on an already-failing substrate. +_PROBE_TTL_SECONDS = 30.0 + class MultiSignalActivation: """Gate + Rerank activation using Neo4j multi-signal scoring. @@ -72,6 +78,7 @@ def __init__( max_nodes: int = 50, k_chunks: int = 100, min_score: float = 0.15, + strict: bool = False, ) -> None: self._connector = connector self._embedding_engine = embedding_engine @@ -79,42 +86,132 @@ def __init__( self._max_nodes = max_nodes self._k_chunks = k_chunks self._min_score = min_score + self._strict = strict self.last_relevance: dict[str, float] = {} self.last_signals: dict[str, dict[str, float]] = {} + #: CR-012 PR-012d (ruling N7). What actually executed on the last + #: ``activate`` call. ``"semantic"`` is the healthy vector path; + #: ``"keyword_fallback"`` means the full graph was returned because + #: activation could not run. The graph-health probe treats + #: ``keyword_fallback`` as degraded. + self.activation_mode: str = "semantic" + #: Chunks with no embedding at the last degradation, when known. + self.chunks_unembedded: int = 0 + #: Cached ``probe_vector_index()`` result + monotonic timestamp. + self._probe_cache: tuple[float, dict[str, Any]] | None = None + + def _probe_cached(self) -> dict[str, Any]: + """Diagnostic probe with a short TTL (sentinel M1). + + ``_degrade`` runs on a path that is ALREADY failing; probing on every + call amplifies load on the degraded resource. The result is diagnostic + metadata, so brief staleness is free. Never raises. + """ + now = time.monotonic() + if self._probe_cache is not None and (now - self._probe_cache[0]) < _PROBE_TTL_SECONDS: + return self._probe_cache[1] + + probe: dict[str, Any] = {} + try: + probe_fn = getattr(self._connector, "probe_vector_index", None) + if callable(probe_fn): + probe = probe_fn() or {} + except Exception: # noqa: BLE001 — diagnosis must never mask the defect + probe = {} + + self._probe_cache = (now, probe) + return probe + + def _degrade(self, graph: Any, reason: str, exc: Exception | None = None) -> list[str]: + """Fall back to the full graph, loudly (CR-012 PR-012d, ruling N7). + + Mirrors ``CypherActivation._degrade``: every degraded activation logs + at ERROR and is visible in ``graph_health``, and ``strict`` converts + the silent fallback into a typed ``VectorIndexMissingError``. + """ + probe = self._probe_cached() + + total = probe.get("chunks_total") + embedded = probe.get("chunks_embedded") + self.chunks_unembedded = ( + max(0, int(total) - int(embedded)) + if isinstance(total, int) and isinstance(embedded, int) + else 0 + ) + self.activation_mode = "keyword_fallback" + + # Sentinel B1: log LEVEL is an observable contract. The default + # (non-strict) path keeps v0.83.0's WARNING so a graceful fallback does + # not start paging operators; only an opt-in strict caller gets ERROR. + # The enriched CONTENT lands on both paths. + logger.log( + logging.ERROR if self._strict else logging.WARNING, + "MultiSignal DEGRADED (%s): returning the full graph, so this " + "result is NOT retrieval-grounded. vector index %r state=%s " + "chunk embedding coverage=%s/%s%s", + reason, + probe.get("index_name", "?"), + probe.get("index_state", "?"), + embedded if embedded is not None else "?", + total if total is not None else "?", + f" ({type(exc).__name__}: {exc})" if exc is not None else "", + ) + + if self._strict: + from graqle.core.exceptions import VectorIndexMissingError + + raise VectorIndexMissingError( + index_name=str(probe.get("index_name", "unknown")), + database=probe.get("database"), + index_state=probe.get("index_state"), + chunks_total=total, + chunks_embedded=embedded, + ) from exc + + self.last_relevance = {nid: 1.0 for nid in graph.nodes} + return list(graph.nodes.keys())[:self._max_nodes] def activate( self, graph: Any, query: str, + strict: bool | None = None, ) -> list[str]: """Multi-signal activation: gate on semantic, rerank with bonuses. + Args: + strict: CR-012 PR-012d per-call override of the constructor's + ``strict``. When True, a substrate that cannot activate raises + ``VectorIndexMissingError`` instead of degrading silently. + Returns list of activated node IDs sorted by final score desc. """ - # 1. Embed the query + previous_strict = self._strict + if strict is not None: + self._strict = strict try: - query_embedding = self._embedding_engine.embed(query) - except Exception as exc: - logger.warning("MultiSignal: embedding failed (%s), falling back", exc) - self.last_relevance = {nid: 1.0 for nid in graph.nodes} - return list(graph.nodes.keys())[:self._max_nodes] + # 1. Embed the query + try: + query_embedding = self._embedding_engine.embed(query) + except Exception as exc: + return self._degrade(graph, "query embedding failed", exc) + + # 2. Phase 1: GATE — semantic vector search + try: + hits = self._connector.vector_search( + query_embedding=query_embedding, + k=self._k_chunks, + max_nodes=self._max_nodes * 3, # Wider gate for reranking + ) + except Exception as exc: + return self._degrade(graph, "vector search failed", exc) - # 2. Phase 1: GATE — semantic vector search - try: - hits = self._connector.vector_search( - query_embedding=query_embedding, - k=self._k_chunks, - max_nodes=self._max_nodes * 3, # Wider gate for reranking - ) - except Exception as exc: - logger.warning("MultiSignal: vector search failed (%s), falling back", exc) - self.last_relevance = {nid: 1.0 for nid in graph.nodes} - return list(graph.nodes.keys())[:self._max_nodes] - - if not hits: - logger.warning("MultiSignal: 0 hits from vector search") - self.last_relevance = {nid: 1.0 for nid in graph.nodes} - return list(graph.nodes.keys())[:self._max_nodes] + if not hits: + return self._degrade(graph, "vector search returned 0 hits") + finally: + self._strict = previous_strict + + self.activation_mode = "semantic" # Filter to min_score gate + in-graph check candidates: list[tuple[str, float]] = [] diff --git a/graqle/assurance/__init__.py b/graqle/assurance/__init__.py index d99691d5..3a923c8d 100644 --- a/graqle/assurance/__init__.py +++ b/graqle/assurance/__init__.py @@ -4,8 +4,8 @@ reverse (CR-012 AC-21). With ``GRAQLE_DAG_ENABLED`` unset nothing in this package changes SDK behaviour; ``GovernanceMiddleware.check()`` is untouched. -PR-012a exports the flag and settings surface only. ``GateOutcome``, the -reason-code registry and ``GateVerdict`` arrive in PR-012b; the +PR-012a exported the flag and settings surface. PR-012b adds ``GateOutcome``, +the closed reason-code registry and the ``GateVerdict`` schema; the ``DecisionAssuranceGate`` component itself arrives in CR-015. """ @@ -16,6 +16,20 @@ # constraints: never imported by graqle.governance.* # ── /graqle:intelligence ── +from graqle.assurance.outcomes import ( + OUTCOME_SEVERITY, + GateOutcome, + worst, + worst_terminal, +) +from graqle.assurance.reason_codes import ( + REGISTRY, + ReasonCode, + Severity, + max_severity, + resolvable, + validate, +) from graqle.assurance.settings import ( ConfigurationError, DagSettings, @@ -26,8 +40,16 @@ reset_dag_settings_cache, validate_flag_consistency, ) +from graqle.assurance.verdict import ( + VERDICT_SCHEMA_VERSION, + DeterminismRecord, + GateVerdict, + GateVerdictRef, + HardGateResultRef, +) __all__ = [ + # PR-012a — flag + settings "ConfigurationError", "DagSettings", "config_provenance", @@ -36,4 +58,22 @@ "load_dag_settings", "reset_dag_settings_cache", "validate_flag_consistency", + # PR-012b — outcomes + "GateOutcome", + "OUTCOME_SEVERITY", + "worst", + "worst_terminal", + # PR-012b — reason codes + "REGISTRY", + "ReasonCode", + "Severity", + "max_severity", + "resolvable", + "validate", + # PR-012b — verdict + "VERDICT_SCHEMA_VERSION", + "DeterminismRecord", + "GateVerdict", + "GateVerdictRef", + "HardGateResultRef", ] diff --git a/graqle/assurance/outcomes.py b/graqle/assurance/outcomes.py new file mode 100644 index 00000000..cb456c9e --- /dev/null +++ b/graqle/assurance/outcomes.py @@ -0,0 +1,96 @@ +"""DAG-2026 gate outcomes (CR-012 §4.1). + +The five outcomes a Decision Assurance Gate evaluation can return. + +``GateOutcome`` is PRIVATE to :mod:`graqle.assurance`. It is deliberately NOT an +extension of :class:`graqle.governance.trace_schema.Decision`: the governance +package must never import the assurance package (CR-012 AC-21), and the two +vocabularies answer different questions. ``Decision`` records what a governance +gate did (PASS/BLOCK/WARN); ``GateOutcome`` records what the caller must do +next. A trace carries the outcome as a plain string via ``GateVerdictRef``. +""" + +# -- graqle:intelligence -- +# module: graqle.assurance.outcomes +# risk: LOW (impact radius: 0 modules -- new file, no existing callers) +# dependencies: enum +# constraints: never imported by graqle.governance.* +# -- /graqle:intelligence -- + +from __future__ import annotations + +from collections.abc import Iterable +from enum import Enum + +__all__ = ["GateOutcome", "OUTCOME_SEVERITY", "worst"] + + +class GateOutcome(str, Enum): + """What the caller must do with the action the gate just evaluated. + + ``str``-valued so it serialises without a custom JSON encoder. + """ + + EXECUTE = "EXECUTE" + REPLAN = "REPLAN" + HOLD = "HOLD" + REJECT = "REJECT" + ESCALATE = "ESCALATE" + + @property + def terminal(self) -> bool: + """True when the outcome ends the decision loop for this action. + + ``REJECT`` and ``ESCALATE`` are terminal: the action is refused, or it + has left the automated path for a human. ``REPLAN`` is explicitly NOT + terminal -- it is a retry outcome that re-enters the gate with a new + candidate, and CR-015's circuit breaker (not this flag) is what stops + an unproductive replan loop. ``HOLD`` is not terminal either: it waits + on recomputation and is re-evaluated. + """ + return self in (GateOutcome.REJECT, GateOutcome.ESCALATE) + + +#: Severity order used by CR-015 when composing multiple signals (worst wins). +#: The ordering is by distance from proceeding, not by how bad the underlying +#: failure is: ESCALATE ranks below REJECT because a human may still authorise +#: the action, whereas REJECT is final. +OUTCOME_SEVERITY: dict[GateOutcome, int] = { + GateOutcome.EXECUTE: 0, + GateOutcome.REPLAN: 1, + GateOutcome.HOLD: 2, + GateOutcome.ESCALATE: 3, + GateOutcome.REJECT: 4, +} + + +def worst(outcomes: Iterable[GateOutcome]) -> GateOutcome: + """Return the highest-severity outcome (worst wins). + + NOTE: the result may be NON-TERMINAL. ``worst([HOLD, HOLD])`` is ``HOLD``, + whose :attr:`GateOutcome.terminal` is ``False`` -- a caller using + ``worst(...).terminal`` as a halt condition will not halt, which is correct + (a HOLD is re-evaluated) but easy to misread. Use :func:`worst_terminal` + when you specifically need the worst terminal outcome. + + Raises: + ValueError: if ``outcomes`` is empty. A gate that produced no outcome + at all is a programming error, not an implicit EXECUTE -- returning + a default here would be a fail-open path. + """ + ranked = sorted(outcomes, key=lambda o: OUTCOME_SEVERITY[o], reverse=True) + if not ranked: + raise ValueError("worst() requires at least one GateOutcome") + return ranked[0] + + +def worst_terminal(outcomes: Iterable[GateOutcome]) -> GateOutcome | None: + """Return the highest-severity TERMINAL outcome, or ``None`` if none is. + + Companion to :func:`worst` for callers whose control flow branches on + "does this end the decision loop?" rather than "how bad is it?". + """ + terminal = [o for o in outcomes if o.terminal] + if not terminal: + return None + return worst(terminal) diff --git a/graqle/assurance/reason_codes.py b/graqle/assurance/reason_codes.py new file mode 100644 index 00000000..bd8c9853 --- /dev/null +++ b/graqle/assurance/reason_codes.py @@ -0,0 +1,160 @@ +"""DAG-2026 reason-code registry (CR-012 §4.2, ruling R1 + R2). + +A CLOSED, append-only registry. Every code a :class:`~graqle.assurance.verdict.GateVerdict` +carries must be registered here; an unregistered code is a construction error, not a +warning. The grammar below is the ONLY reason-code regex in the SDK — CR-014, CR-015, +CR-017 and CR-018 register through :data:`REGISTRY`, never via a local pattern (R1). + +Severity vocabulary is FAIL/CRITICAL/WARN/INFO (R2: ``BLOCK -> FAIL``, ``FATAL -> CRITICAL``). + +Remediation hints are PUBLIC strings and must never state a numeric threshold, cap, +weight or budget (CR-012 §12). +""" + +# -- graqle:intelligence -- +# module: graqle.assurance.reason_codes +# risk: LOW (impact radius: 0 modules -- new file, no existing callers) +# dependencies: dataclasses, enum, re, types +# constraints: never imported by graqle.governance.*; append-only registry +# -- /graqle:intelligence -- + +from __future__ import annotations + +import re +from dataclasses import dataclass +from enum import Enum +from types import MappingProxyType + +__all__ = [ + "CODE_PATTERN", + "REGISTRY", + "ReasonCode", + "Severity", + "max_severity", + "resolvable", + "validate", +] + + +class Severity(str, Enum): + """Reason-code severity (ruling R2).""" + + INFO = "INFO" + WARN = "WARN" + FAIL = "FAIL" + CRITICAL = "CRITICAL" + + +#: Rank used by :func:`max_severity`. Higher is worse. +_SEVERITY_RANK: dict[Severity, int] = { + Severity.INFO: 0, + Severity.WARN: 1, + Severity.FAIL: 2, + Severity.CRITICAL: 3, +} + +#: R1 (2026-09-13): the ONLY reason-code grammar in the SDK. Widened once in +#: PR-012b; hyphens are legal after the namespace segment. +CODE_PATTERN = re.compile(r"^DAG-(HG0[1-7]|CV|TR|PV|RP)-[A-Z0-9_-]{2,48}$") + +_MAX_HINT_CHARS = 200 + + +@dataclass(frozen=True, slots=True) +class ReasonCode: + """One registered reason code. + + Invariants enforced at construction: + * INV-RC-1 grammar — ``code`` matches :data:`CODE_PATTERN`. + * INV-RC-3 — a hard-gate code (``DAG-HG0x-*``) is never advisory: its + severity must be FAIL or CRITICAL. + * ``remediation_hint`` is a public, number-free string within the length cap. + """ + + code: str + severity: Severity + owner_gate: str + remediation_hint: str + since_version: str + + def __post_init__(self) -> None: + if not CODE_PATTERN.match(self.code): + raise ValueError(f"ReasonCode.code {self.code!r} violates grammar") + if self.owner_gate.startswith("HG-") and self.severity in ( + Severity.INFO, + Severity.WARN, + ): + raise ValueError( + "hard-gate reason codes must be FAIL or CRITICAL (INV-RC-3)" + ) + if len(self.remediation_hint) > _MAX_HINT_CHARS: + raise ValueError("remediation_hint exceeds the length cap") + + +#: The registry seed. Each code family is seeded by the change request that +#: introduces it; 0.84.1 ships the grammar and the invariants, and seeding +#: begins with CR-013. INV-RC-2 (append-only, monotone ``since_version``) holds: +#: appending to an empty seed is a plain append. +_SEED: tuple[ReasonCode, ...] = () + +#: The closed registry. Immutable after import (INV-RC-2: append-only across +#: versions; a code is never re-used with a different meaning). +REGISTRY: MappingProxyType[str, ReasonCode] = MappingProxyType( + {rc.code: rc for rc in _SEED} +) + + +def validate(codes: list[str]) -> list[str]: + """Return ``codes`` if every entry is registered, preserving order. + + Duplicates are removed (first occurrence wins). + + Raises: + ValueError: listing the unregistered codes. Unknown codes fail closed — + a verdict may not carry a reason nobody can look up. + """ + unknown = [c for c in codes if c not in REGISTRY] + if unknown: + raise ValueError(f"unregistered reason codes: {unknown}") + seen: set[str] = set() + out: list[str] = [] + for c in codes: + if c not in seen: + seen.add(c) + out.append(c) + return out + + +def max_severity(codes: list[str]) -> Severity | None: + """Highest severity across ``codes``; ``None`` for an empty list. + + Raises: + ValueError: if any code is unregistered (via :func:`validate`). + """ + validate(codes) + if not codes: + return None + return max((REGISTRY[c].severity for c in codes), key=lambda s: _SEVERITY_RANK[s]) + + +def resolvable(code: str) -> bool: + """Whether ``code`` can in principle be cleared by a replan (ruling R2). + + ``resolvable := severity in {INFO, WARN} or (severity == FAIL and + remediation_hint != "")``. This is the machine-checkable predicate that + drives REPLAN eligibility in CR-015. + + CRITICAL is NEVER resolvable -- deliberately, and regardless of how good its + remediation hint is. A CRITICAL code means the action is refused or must + leave the automated path; letting a hint downgrade that would make the + hard gates advisory, which INV-RC-3 exists to prevent. + + Raises: + ValueError: if ``code`` is unregistered. + """ + if code not in REGISTRY: + raise ValueError(f"unregistered reason code: {code!r}") + entry = REGISTRY[code] + if entry.severity in (Severity.INFO, Severity.WARN): + return True + return entry.severity == Severity.FAIL and entry.remediation_hint != "" diff --git a/graqle/assurance/settings.py b/graqle/assurance/settings.py index 076157f0..2c7522d4 100644 --- a/graqle/assurance/settings.py +++ b/graqle/assurance/settings.py @@ -54,7 +54,11 @@ from pydantic import Field, SecretStr, ValidationError, field_validator, model_validator from pydantic_settings import BaseSettings, SettingsConfigDict -from graqle.config.exceptions import GraqleConfigError +from graqle.config.exceptions import ( # noqa: F401 (re-exported, see below) + ENV_FLAG, + ConfigurationError, + GraqleConfigError, +) logger = logging.getLogger(__name__) @@ -76,7 +80,11 @@ ] #: The single feature flag. Positive allowlist; anything else is OFF. -ENV_FLAG = "GRAQLE_DAG_ENABLED" +#: CR-012 / PR-012b (ruling N5 + R-C): ``ENV_FLAG`` is DEFINED in +#: ``graqle.config.exceptions`` and imported above. It is re-exported here so +#: existing ``from graqle.assurance.settings import ENV_FLAG`` imports keep +#: working. Do not redefine it: ``config/settings.py`` reads the flag on the +#: flag-OFF path without importing this module. #: Prefix shared by every DAG setting (``GRAQLE_DAG_``). ENV_PREFIX = "GRAQLE_DAG_" #: Default location of the gitignored private-values file (brief §8.3). @@ -167,13 +175,10 @@ def _is_absent(value: object) -> bool: _ImpactTier = Literal["LOW", "MEDIUM", "HIGH", "CRITICAL"] -class ConfigurationError(GraqleConfigError): - """Raised on flag mismatch, on missing required DAG config while the flag is - on, or on an invalid value. Never a silent placeholder fallback. - - Messages carry setting NAMES only — never the offending value — so a - rejected private value cannot leak through logs (Senior chain 4). - """ +#: CR-012 / PR-012b (ruling R-C): ``ConfigurationError`` is DEFINED in +#: ``graqle.config.exceptions`` (inheriting ``GraqleConfigError``) and imported +#: above, so catching ``GraqleConfigError`` also catches a DAG config failure. +#: It is re-exported from this module so existing imports keep working. def env_name(field: str) -> str: diff --git a/graqle/assurance/verdict.py b/graqle/assurance/verdict.py new file mode 100644 index 00000000..5c9c4c3f --- /dev/null +++ b/graqle/assurance/verdict.py @@ -0,0 +1,151 @@ +"""DAG-2026 gate verdict schema (CR-012 §4.3). + +What a Decision Assurance Gate evaluation produces. Every model here is +``extra="forbid"``: an unknown field is a schema error, never silently carried. + +Fail-closed invariants (enforced by validators, not by convention): + * EXECUTE is impossible when any hard gate failed. + * EXECUTE is impossible when ``evaluation_errors`` is non-empty. + * A :class:`HardGateResultRef` carrying an ``error`` must not be ``passed``. + +``confidence_vector`` and ``trajectory_reliability`` stay optional until CR-014 +and CR-015 populate them. +""" + +# -- graqle:intelligence -- +# module: graqle.assurance.verdict +# risk: LOW (impact radius: 0 modules -- new file, no existing callers) +# dependencies: pydantic, graqle.assurance.outcomes, graqle.assurance.reason_codes +# constraints: never imported by graqle.governance.* +# -- /graqle:intelligence -- + +from __future__ import annotations + +from datetime import datetime + +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator + +from graqle.assurance.outcomes import GateOutcome +from graqle.assurance.reason_codes import validate as _validate_codes + +__all__ = [ + "VERDICT_SCHEMA_VERSION", + "DeterminismRecord", + "GateVerdict", + "GateVerdictRef", + "HardGateResultRef", +] + +#: Wire-format version of the verdict shape itself (independent of the trace +#: schema version). +VERDICT_SCHEMA_VERSION = "1" + + +class DeterminismRecord(BaseModel): + """Why this verdict is reproducible. + + ``inputs_hash`` is ``sha256:`` over the RFC 8785 canonical bytes of the + evaluation inputs (``graqle.governance.tamper_evidence.canonicalize.canon``). + ``config_version`` is the salt-keyed HMAC commitment over ``DagSettings`` + (CR-012 §5.1, ruling N2) -- a commitment, never the values themselves. + """ + + model_config = ConfigDict(extra="forbid") + + inputs_hash: str = Field(pattern=r"^sha256:[0-9a-f]{64}$") + rule_id: str + rule_version: str + config_version: str + decisive_conditions: list[str] = Field(default_factory=list) + probabilistic: bool = False + variance: float | None = None + + @model_validator(mode="after") + def _prob_needs_variance(self) -> DeterminismRecord: + if self.probabilistic and self.variance is None: + raise ValueError("probabilistic signals must report variance") + return self + + +class HardGateResultRef(BaseModel): + """Compact projection of a CR-013 HardGateResult carried in the verdict.""" + + model_config = ConfigDict(extra="forbid") + + rule_id: str + rule_version: str + passed: bool + decisive_conditions: list[str] = Field(default_factory=list) + inputs_hash: str + evaluated_at: datetime + error: str | None = None + + @model_validator(mode="after") + def _error_implies_fail(self) -> HardGateResultRef: + if self.error is not None and self.passed: + raise ValueError("a hard gate with error must not pass") + return self + + +class GateVerdict(BaseModel): + """The full verdict. Stored by CR-018; the trace keeps :class:`GateVerdictRef`.""" + + model_config = ConfigDict(extra="forbid") + + schema_version: str = VERDICT_SCHEMA_VERSION + outcome: GateOutcome + reason_codes: list[str] = Field(default_factory=list) + hard_gate_results: list[HardGateResultRef] = Field(default_factory=list) + confidence_vector: dict[str, float] | None = None + trajectory_reliability: float | None = Field(default=None, ge=0.0, le=1.0) + residual_risk: float | None = Field(default=None, ge=0.0, le=1.0) + implicated_nodes: list[str] = Field(default_factory=list) + decisive_rule: str | None = None + evaluation_errors: list[str] = Field(default_factory=list) + determinism_record: DeterminismRecord + cap_applied: bool = False + evaluated_at: datetime + + @field_validator("reason_codes") + @classmethod + def _codes_registered(cls, value: list[str]) -> list[str]: + return _validate_codes(value) + + @model_validator(mode="after") + def _consistency(self) -> GateVerdict: + if self.outcome == GateOutcome.EXECUTE: + if any(not h.passed for h in self.hard_gate_results): + raise ValueError("EXECUTE is impossible with a failed hard gate") + if self.evaluation_errors: + raise ValueError( + "EXECUTE is impossible with evaluation_errors (fail-closed)" + ) + if self.confidence_vector is not None: + for key, score in self.confidence_vector.items(): + if not 0.0 <= score <= 1.0: + raise ValueError(f"confidence_vector[{key}] out of range") + return self + + +class GateVerdictRef(BaseModel): + """Stable projection of a verdict for embedding in a governed trace. + + A byte-identical copy of this shape lives in + :mod:`graqle.governance.trace_schema` so that ``graqle.governance`` never + imports ``graqle.assurance`` (CR-012 AC-21). ``outcome`` is a plain ``str`` + on purpose -- the trace side must not import the enum. A parity test keeps + the two definitions field-identical (CR-012 OQ-3). + + Carries hashes and codes only: never a confidence vector, never a weight. + """ + + model_config = ConfigDict(extra="forbid") + + verdict_schema_version: str + outcome: str + reason_codes: list[str] + decisive_rule: str | None = None + cap_applied: bool = False + inputs_hash: str + config_version: str + evaluation_error_count: int = 0 diff --git a/graqle/cli/commands/doctor.py b/graqle/cli/commands/doctor.py index e6835fea..03fca884 100644 --- a/graqle/cli/commands/doctor.py +++ b/graqle/cli/commands/doctor.py @@ -611,6 +611,64 @@ def _check_skill_system() -> list[CheckResult]: return results +def _check_neo4j_activation(cfg: dict) -> list[CheckResult]: + """CR-012 PR-012d (ruling N7) — can semantic activation actually run? + + Reports vector-index state and chunk-embedding coverage for neo4j-first + projects. Deliberately a standalone function so it is reachable even when + the optional ``neo4j`` driver is absent: a missing driver means activation + cannot run, which is information the operator needs, not a reason to stay + silent. + + Never raises — every failure mode becomes a row. + """ + graph_cfg = cfg.get("graph", {}) if isinstance(cfg, dict) else {} + try: + from graqle.connectors.neo4j import Neo4jConnector + except ImportError: + return [( + FAIL, + "Neo4j: activation", + "neo4j driver not installed — semantic activation cannot run; " + "install graqle[neo4j]", + )] + + try: + connector = Neo4jConnector( + uri=graph_cfg.get("uri", "bolt://localhost:7687"), + username=graph_cfg.get("username", "neo4j"), + password=graph_cfg.get("password", ""), + database=graph_cfg.get("database", "neo4j"), + ) + try: + probe = connector.probe_vector_index() + finally: + close = getattr(connector, "close", None) + if callable(close): + close() + except Exception as exc: # noqa: BLE001 — a doctor check never crashes + return [(WARN, "Neo4j: activation", f"probe failed ({exc})")] + + state = probe.get("index_state") or "unknown" + total = probe.get("chunks_total") + embedded = probe.get("chunks_embedded") + coverage = ( + f"{embedded}/{total} chunks embedded" + if isinstance(total, int) and isinstance(embedded, int) + else "coverage unknown" + ) + detail = f"index {probe.get('index_name')} {state}; {coverage}" + + if probe.get("usable"): + return [(PASS, "Neo4j: activation", detail)] + return [( + FAIL, + "Neo4j: activation", + f"{detail} — semantic activation CANNOT run; results would fall back " + f"to the full graph", + )] + + def _check_neo4j_backend() -> list[CheckResult]: """Check Neo4j availability and show latency comparison.""" import json @@ -668,6 +726,20 @@ def _check_neo4j_backend() -> list[CheckResult]: results.append((FAIL, "Backend: Neo4j", "configured but neo4j driver not installed")) except Exception as e: results.append((WARN, "Backend: Neo4j", f"configured but connection failed: {e}")) + + # CR-012 PR-012d (ruling N7): on a neo4j-first project, report whether + # semantic activation can actually run. A missing vector index or zero + # chunk embeddings means every retrieval-dependent answer is produced + # from a substrate that cannot activate — the defect that previously + # showed up nowhere. + # + # This sits OUTSIDE the driver-dependent try/except above on purpose. + # `from neo4j import GraphDatabase` raises ImportError wherever the + # optional [neo4j] extra is absent (CI installs only [dev]), which + # aborted the whole block and silently skipped this line — the exact + # class of invisible gap ruling N7 exists to close. A missing driver is + # itself a reportable answer: activation cannot run without it. + results.extend(_check_neo4j_activation(cfg if isinstance(cfg, dict) else {})) else: # On JSON/NetworkX — show upgrade opportunity results.append((INFO, "Backend: JSON", "using file-based graph")) diff --git a/graqle/config/exceptions.py b/graqle/config/exceptions.py index 0f33f3b2..f2985869 100644 --- a/graqle/config/exceptions.py +++ b/graqle/config/exceptions.py @@ -27,6 +27,28 @@ class GraqleConfigError(GraqleError): """ +#: CR-012 / PR-012b (ruling N5 + R-C): the single DAG feature flag name lives +#: here, not in ``graqle.assurance``. ``graqle/config/settings.py`` reads it on +#: the flag-OFF path without importing the assurance package, which is what +#: keeps ``graqle.assurance`` out of the import graph of every pre-existing +#: module when the flag is off. +ENV_FLAG = "GRAQLE_DAG_ENABLED" + + +class ConfigurationError(GraqleConfigError): + """Raised on DAG flag mismatch, on a missing required DAG setting while the + flag is on, or on an invalid value. Never a silent placeholder fallback. + + Ruling R-C (2026-09-15): this inherits :class:`GraqleConfigError` so that a + caller catching the broad config-error class also catches a DAG config + failure. It is re-exported from ``graqle.assurance.settings`` so existing + imports keep working. + + Messages carry setting NAMES only -- never the offending value -- so a + rejected private value cannot leak through logs (Senior chain 4). + """ + + class ConfigNotFoundError(GraqleConfigError): """Raised when ``graqle.yaml`` could not be found within the ancestor walk. diff --git a/graqle/connectors/neo4j.py b/graqle/connectors/neo4j.py index 4d723a6b..8658ceae 100644 --- a/graqle/connectors/neo4j.py +++ b/graqle/connectors/neo4j.py @@ -169,8 +169,18 @@ def load(self) -> tuple[dict[str, Any], dict[str, Any]]: for record in result: nid = str(record["id"]) props = dict(record.get("properties", {})) - # Remove keys already extracted to avoid duplication - for k in ("id", "label", "entity_type", "description"): + # Remove keys already extracted to avoid duplication. + # + # CR-012 PR-012c (rulings process note 1): `type` MUST be in + # this list. The node query aliases `n.entity_type AS type`, so + # a node that ALSO carries a literal `type` property leaves it + # in `props`; `Graqle.to_networkx` then calls + # `G.add_node(nid, ..., type=node.entity_type, **node.properties)` + # and Python raises "got multiple values for keyword argument + # 'type'". 0.79.0 tolerated it; the 0.83.0 hydrator collides. + # The writer already excludes the same keys (see `save`), so + # this restores symmetry between the read and write paths. + for k in ("id", "label", "entity_type", "description", "type"): props.pop(k, None) nodes[nid] = { "label": record.get("label") or nid, @@ -689,6 +699,64 @@ def _write(tx: Any) -> bool: # --- Vector search --- + def probe_vector_index(self) -> dict[str, Any]: + """Report whether semantic activation can actually run here. + + CR-012 PR-012d (ruling N7). ``vector_search`` cannot distinguish an + ABSENT index from a present index that simply matched nothing — the + driver surfaces both to a bare ``except`` identically. This probe + separates them so the caller can raise a typed + :class:`~graqle.core.exceptions.VectorIndexMissingError` instead of + silently falling back to the whole graph. + + Never raises: on any driver/query failure it returns + ``{"usable": False, "index_state": None, ...}`` with ``error`` set, so + a health probe or ``graq doctor`` line can report the degradation + rather than crash on it. + + Returns a dict with ``index_name``, ``database``, ``index_state`` + (``"ONLINE"`` / ``"POPULATING"`` / ``"NOT_FOUND"`` / ``None``), + ``chunks_total``, ``chunks_embedded`` and ``usable``. ``usable`` is + True only when the index is ONLINE **and** at least one chunk carries + an embedding — a chunk set with zero embeddings is exactly the D1 + failure mode, where the index exists but can never match. + """ + info: dict[str, Any] = { + "index_name": self._vector_index_name, + "database": self._database, + "index_state": None, + "chunks_total": None, + "chunks_embedded": None, + "usable": False, + } + try: + driver = self._get_driver() + with driver.session(database=self._database) as session: + record = session.run( + "SHOW INDEXES YIELD name, state " + "WHERE name = $idx RETURN state", + idx=self._vector_index_name, + ).single() + info["index_state"] = record["state"] if record else "NOT_FOUND" + + counts = session.run( + "MATCH (c:Chunk) " + "RETURN count(c) AS total, " + "count(c.embedding) AS embedded" + ).single() + if counts is not None: + info["chunks_total"] = int(counts["total"]) + info["chunks_embedded"] = int(counts["embedded"]) + except Exception as exc: # noqa: BLE001 — probe must never raise + info["error"] = str(exc) + return info + + info["usable"] = ( + info["index_state"] == "ONLINE" + and (info["chunks_embedded"] or 0) > 0 + ) + return info + def vector_search( self, query_embedding: list[float], diff --git a/graqle/core/exceptions.py b/graqle/core/exceptions.py index a709f2a4..cbf4a05a 100644 --- a/graqle/core/exceptions.py +++ b/graqle/core/exceptions.py @@ -38,6 +38,68 @@ def __init__( self.active_dim = active_dim +class VectorIndexMissingError(GraqleError): + """Raised when semantic activation cannot run because the backing vector + index is absent, offline, or carries no usable embeddings. + + CR-012 PR-012d (ruling N7). Before this existed, a neo4j-first project whose + substrate had no vector index (or zero chunk embeddings) degraded SILENTLY: + ``vector_search`` raised deep inside the driver, the activator caught the + bare ``Exception``, logged at WARNING, and returned the whole graph. Every + retrieval-dependent answer was then produced from a substrate that could not + activate, with no error and no signal in ``graph_health`` or ``graq doctor``. + + The failure is now typed so callers can distinguish "the index is missing" + from "the index is fine and matched nothing" — two conditions the driver + reports identically to a bare ``except``. + + Raised only when ``strict=True`` is passed to the activation entry point. + The default remains non-raising to preserve v0.83.0 behaviour, but the + degraded path now logs at ERROR and is visible in ``graph_health``. + + Recovery: + Create the vector index and embed the chunks, e.g. + ``graq doctor`` reports the index state and chunk embedding coverage. + """ + + def __init__( + self, + *, + index_name: str, + database: str | None = None, + index_state: str | None = None, + chunks_total: int | None = None, + chunks_embedded: int | None = None, + ) -> None: + self.index_name = index_name + self.database = database + self.index_state = index_state + self.chunks_total = chunks_total + self.chunks_embedded = chunks_embedded + + where = f" in database {database!r}" if database else "" + if index_state in (None, "NOT_FOUND"): + cause = f"vector index {index_name!r} not found{where}" + elif index_state != "ONLINE": + cause = f"vector index {index_name!r} is {index_state}{where}" + else: + cause = f"vector index {index_name!r} has no usable embeddings{where}" + + coverage = "" + if chunks_total is not None and chunks_embedded is not None: + coverage = ( + f" Chunk embedding coverage: {chunks_embedded}/{chunks_total}." + ) + + super().__init__( + f"Semantic activation unavailable: {cause}.{coverage} " + f"Activation would silently fall back to the full graph, so every " + f"retrieval-dependent result would come from a substrate that " + f"cannot activate. Run 'graq doctor' for index state and chunk " + f"embedding coverage." + ) + + class GovernanceViolation(GraqleError): """Policy violation (clearance laundering, taint escalation, redaction bypass). diff --git a/graqle/core/governance.py b/graqle/core/governance.py index 24151ee4..a1173b24 100644 --- a/graqle/core/governance.py +++ b/graqle/core/governance.py @@ -510,7 +510,17 @@ def __init__( learn_callback: Optional[Callable[[dict[str, Any]], None]] = None, policy: Optional[Any] = None, # GovernancePolicyConfig | None ) -> None: - self.config = config or GovernanceConfig() + # CR-012 PR-012c (AC-11): an explicit config still wins. When none is + # given, resolve from graqle.yaml instead of silently discarding the + # operator's `governance:` block. With no yaml the resolver returns + # exactly GovernanceConfig(), so flag-off behaviour is byte-identical + # (AC-9 golden, generated on the v0.83.0 tag). + if config is not None: + self.config = config + else: + from graqle.core.governance_thresholds import resolve_governance_config + + self.config = resolve_governance_config() self._audit_log = audit_log if audit_log is not None else GovernanceAuditLog() self._learn_callback = learn_callback # Lazy policy load — governance_policy.py is a sibling module (pure stdlib) diff --git a/graqle/core/governance_thresholds.py b/graqle/core/governance_thresholds.py new file mode 100644 index 00000000..42eaff5d --- /dev/null +++ b/graqle/core/governance_thresholds.py @@ -0,0 +1,128 @@ +"""Single resolver for governance thresholds (CR-012 PR-012c, AC-9..AC-11). + +The defect this closes: ``GraqleConfig.governance`` (a ``GovernancePolicyConfig`` +parsed from ``graqle.yaml``) and ``GovernanceConfig`` (the dataclass +``GovernanceMiddleware`` actually runs on) declare the SAME eight threshold keys +independently, and nothing ever copied one into the other. +``GovernanceMiddleware.__init__`` did ``config or GovernanceConfig()``, so an +operator who set ``governance.review_threshold`` in ``graqle.yaml`` got the +hard-coded default and no warning. The yaml keys were inert. + +Two rules govern this module: + +* **Defaults are byte-identical.** With no yaml present, ``resolve_governance_config()`` + returns exactly ``GovernanceConfig()``. AC-9's 64-case golden — generated on the + v0.83.0 tag — pins that: flag-off behaviour must not move. +* **Values never reach a log.** A threshold is TS-3 tuning. When yaml overrides a + default this module logs the KEY NAME only, once, at WARNING. Never the value, + never the default it replaced. +""" + +# ── graqle:intelligence ── +# module: graqle.core.governance_thresholds +# risk: LOW (new leaf; one caller — core/governance.py:__init__) +# dependencies: graqle.core.governance (GovernanceConfig) +# constraints: never logs a threshold VALUE (TS-3); defaults byte-identical to v0.83.0 +# ── /graqle:intelligence ── + +from __future__ import annotations + +import logging +from typing import Any + +logger = logging.getLogger("graqle.core.governance_thresholds") + +#: The keys BOTH config shapes declare. AC-10's drift lock asserts this set +#: stays in sync with the two classes — if either grows a shared key and this +#: tuple is not updated, the resolver would silently ignore it. +SHARED_KEYS: tuple[str, ...] = ( + "ts_hard_block", + "ts_patterns_file", + "review_threshold", + "block_threshold", + "auto_pass_max_radius", + "auto_pass_max_risk", + "cumulative_radius_cap", + "cumulative_window_hours", +) + +#: Warn once per key per process. A gate runs on every tool call; repeating the +#: warning would bury it. +_warned: set[str] = set() + + +def _reset_warned_for_tests() -> None: + """Clear the warn-once memo. Test-support only.""" + _warned.clear() + + +def resolve_governance_config(policy: Any = None) -> Any: + """Build the ``GovernanceConfig`` the middleware should run on. + + Args: + policy: a ``GovernancePolicyConfig`` (from ``GraqleConfig.governance``), + or ``None`` to load it from the resolved ``graqle.yaml``. Anything + that raises while loading yields plain defaults — a governance gate + must never fail to construct because config is unreadable. + + Returns: + ``GovernanceConfig``. With no yaml and no overrides this is exactly + ``GovernanceConfig()`` (AC-9). + """ + from graqle.core.governance import GovernanceConfig + + defaults = GovernanceConfig() + + if policy is None: + policy = _load_policy_from_yaml() + if policy is None: + return defaults + + # A governance gate must construct even when the config object misbehaves. + # `hasattr` only swallows AttributeError — a property that raises anything + # else (RuntimeError from a config backend, say) would propagate and take + # the gate down with it. Guard the whole read. + overrides: dict[str, Any] = {} + try: + for key in SHARED_KEYS: + try: + value = getattr(policy, key) + except Exception: # noqa: BLE001 — one bad key must not lose the rest + logger.debug("governance.%s unreadable; keeping the default", key) + continue + if value is None: + continue + if value != getattr(defaults, key, None): + overrides[key] = value + except Exception: # noqa: BLE001 — defaults are always a safe answer + logger.debug("governance policy unreadable; using defaults") + return defaults + + if not overrides: + return defaults + + # TS-3: names only. Never the value, never the default it replaced. + for key in sorted(overrides): + if key not in _warned: + _warned.add(key) + logger.warning( + "governance.%s from graqle.yaml overrides the built-in default; " + "this threshold was previously ignored (CR-012 PR-012c)", + key, + ) + + return GovernanceConfig(**{**{k: getattr(defaults, k) for k in SHARED_KEYS}, **overrides}) + + +def _load_policy_from_yaml() -> Any: + """Best-effort ``GraqleConfig.governance``. Never raises.""" + try: + from graqle.config.settings import GraqleConfig + + loader = getattr(GraqleConfig, "from_yaml", None) + if loader is None: + return None + return getattr(loader("graqle.yaml"), "governance", None) + except Exception: # noqa: BLE001 — the gate must construct regardless + logger.debug("governance policy not loadable from yaml; using defaults") + return None diff --git a/graqle/governance/trace_schema.py b/graqle/governance/trace_schema.py index d03c7850..5d70d616 100644 --- a/graqle/governance/trace_schema.py +++ b/graqle/governance/trace_schema.py @@ -26,14 +26,25 @@ from __future__ import annotations +import logging import math +import os import re from datetime import datetime, timezone from enum import Enum from typing import Any from uuid import UUID, uuid4 -from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator +from pydantic import ( + BaseModel, + ConfigDict, + Field, + ValidationError, + field_validator, + model_validator, +) + +logger = logging.getLogger("graqle.governance.trace_schema") # --------------------------------------------------------------------------- @@ -131,7 +142,13 @@ class GovernanceDecision(BaseModel): # "1" before this CR introduced explicit versioning). Pre-cr-017 records on # disk have NO ``schema_version`` field at all and are treated as v1 by # :func:`classify_schema_version`. -CURRENT_SCHEMA_VERSION: str = "2" +CURRENT_SCHEMA_VERSION: str = "3" + +# Every wire version this module can READ. Bumped to include "3" in CR-012 +# PR-012b, which adds the optional ``assurance`` projection (INV-TS-1: the v3 +# field set is a strict superset of v2, so every v2 record validates under the +# v3 model). +SUPPORTED_SCHEMA_VERSIONS: frozenset[str] = frozenset({"1", "2", "3"}) def classify_schema_version(raw: dict[str, Any] | None) -> str: @@ -155,6 +172,85 @@ def classify_schema_version(raw: dict[str, Any] | None) -> str: return "1" +def _pinned_version() -> str: + """Reader pin. Unset means the current writer version (CR-012 §5.5).""" + return ( + os.environ.get("GRAQLE_TRACE_SCHEMA_VERSION", CURRENT_SCHEMA_VERSION).strip() + or CURRENT_SCHEMA_VERSION + ) + + +def _strict() -> bool: + """``GRAQLE_TRACE_SCHEMA_STRICT`` — positive allowlist, anything else OFF.""" + return os.environ.get("GRAQLE_TRACE_SCHEMA_STRICT", "").strip().lower() in { + "1", + "true", + "yes", + "on", + } + + +def read_trace(raw: dict[str, Any], *, strict: bool | None = None) -> GovernedTrace: + """Read a serialized trace of ANY supported schema version. + + This is the ONLY correct way to deserialize a trace record read from disk. + Constructing ``GovernedTrace.model_validate(raw)`` directly stamps a legacy + record (which carries no ``schema_version`` on disk) with the current + default, asserting a schema generation the record predates. That value + reaches ``governance_metadata`` inside the frozen ``LEAF_HASH_FIELDS`` + allowlist, so a relabelled record hashes to a different Merkle leaf than it + did when committed. This function preserves the original version instead. + + ``raw`` is never mutated. + + Args: + raw: a trace dict parsed from JSONL. + strict: override the ``GRAQLE_TRACE_SCHEMA_STRICT`` environment switch. + + Returns: + A validated :class:`GovernedTrace` whose ``schema_version`` is the + version the record was WRITTEN under, not the current one. + + Raises: + ValueError: if the record fails validation, or if its version is + unsupported while strict mode is on. + """ + strict = _strict() if strict is None else strict + version = classify_schema_version(raw) + + if version not in SUPPORTED_SCHEMA_VERSIONS: + if strict: + raise ValueError( + f"trace schema_version {version!r} unsupported " + f"(pinned {_pinned_version()!r}); STRICT is on" + ) + known = set(GovernedTrace.model_fields) + dropped = sorted(key for key in raw if key not in known) + logger.warning( + "trace schema_version %r is newer than pinned %r; best-effort read, " + "dropped unknown keys %s", + version, + _pinned_version(), + dropped, + ) + raw = {key: value for key, value in raw.items() if key in known} + + data = dict(raw) + if version in ("1", "2"): + # Preserve the version the record was written under (v1 records carry + # no field at all) and make the additive v3 field explicit. + data.setdefault("schema_version", version) + data.setdefault("assurance", None) + + try: + return GovernedTrace.model_validate(data) + except ValidationError as exc: + raise ValueError( + f"trace {raw.get('id')} failed v{version} validation: " + f"{exc.errors(include_url=False)}" + ) from exc + + def get_policy_version_or_sentinel(raw: dict[str, Any] | None) -> str: """Read ``policy_version`` from a serialized trace, returning the sentinel if absent or ``None``. Used by audit-export, OPSF Use B validation, and @@ -168,6 +264,31 @@ def get_policy_version_or_sentinel(raw: dict[str, Any] | None) -> str: return LEGACY_POLICY_VERSION_SENTINEL +class GateVerdictRef(BaseModel): + """Stable projection of a DAG-2026 gate verdict carried on a trace. + + This shape is DUPLICATED from :class:`graqle.assurance.verdict.GateVerdictRef` + on purpose: ``graqle.governance`` must never import ``graqle.assurance`` + (CR-012 AC-21, enforced by the import-linter contract). ``outcome`` is a + plain ``str`` here rather than the ``GateOutcome`` enum for the same reason. + A parity test keeps the two definitions field-identical (CR-012 OQ-3). + + Carries hashes, codes and counts only -- never a confidence vector, never a + weight, never a threshold (TS-1/TS-2). + """ + + model_config = ConfigDict(extra="forbid") + + verdict_schema_version: str + outcome: str + reason_codes: list[str] + decisive_rule: str | None = None + cap_applied: bool = False + inputs_hash: str + config_version: str + evaluation_error_count: int = 0 + + class GovernedTrace(BaseModel): """A single governed execution trace record. @@ -220,9 +341,24 @@ class GovernedTrace(BaseModel): # ``None``, the reader-side sentinel ``"legacy_pre_v058_unknown"`` is # returned by helpers that need a non-null value (see Research-Team # v0.58.x directive item #2; OPSF PCT comment 4 alignment). - schema_version: str = "2" + schema_version: str = "3" policy_version: str | None = None + # -- CR-012 PR-012b: DAG-2026 assurance projection (ADDITIVE) ----------- + # + # ``None`` on every trace until CR-015 writes a verdict, and excluded from + # serialisation while it is None (see :meth:`to_internal_dict`) so an + # on-disk v3 record with no verdict stays byte-compatible with a v0.83.0 + # reader under ``extra="forbid"``. + # + # TS-2: excluded from :meth:`to_public_dict` -- reason codes and hard-gate + # results are internal. + assurance: GateVerdictRef | None = Field( + default=None, + repr=False, + json_schema_extra={"internal": True}, + ) + # -- Validators -------------------------------------------------------- @field_validator("query") @@ -267,12 +403,33 @@ def validate_override(self) -> GovernedTrace: # -- Serialization ----------------------------------------------------- def to_public_dict(self) -> dict[str, Any]: - """Serialize excluding TS-2 gated governance_decisions.""" + """Serialize excluding TS-2 gated fields. + + ``assurance`` joins ``governance_decisions`` in the exclusion set + (INV-TS-3): reason codes and hard-gate results are internal. + """ return self.model_dump( mode="json", - exclude={"governance_decisions"}, + exclude={"governance_decisions", "assurance"}, ) def to_internal_dict(self) -> dict[str, Any]: - """Full serialization including all governance fields.""" - return self.model_dump(mode="json") + """Full serialization including all governance fields. + + CR-012 PR-012b: ``assurance`` is omitted entirely while it is ``None`` + (rather than emitted as ``null``) so that a v3 record written before + CR-015 ships remains readable by a v0.83.0 reader, whose model is + ``extra="forbid"`` and would otherwise reject the unknown key. Once a + verdict is attached the key is present and only v3 readers accept it. + + The omission lives here rather than at the call site because this method + IS the write path -- ``TraceStore.append`` serialises with it and is its + only production caller -- and because every writer must agree on the + on-disk shape. ``assurance is None`` and "key absent" are the same state + on read: :func:`read_trace` restores it with ``setdefault``, so the + round trip is lossless. + """ + data = self.model_dump(mode="json") + if self.assurance is None: + data.pop("assurance", None) + return data diff --git a/graqle/governance/trace_store.py b/graqle/governance/trace_store.py index 0d28e592..34c0b983 100644 --- a/graqle/governance/trace_store.py +++ b/graqle/governance/trace_store.py @@ -135,6 +135,7 @@ def read_traces( self, date: str | None = None, limit: int = 100, + validate: bool = False, ) -> list[dict[str, Any]]: """Read traces from a daily JSONL file. @@ -144,6 +145,13 @@ def read_traces( ISO date string (YYYY-MM-DD). Defaults to today (UTC). limit: Maximum number of traces to return (most recent first). + validate: + CR-012 PR-012b. When True, every record is routed through + :func:`graqle.governance.trace_schema.read_trace`, which validates + it against the schema version it was WRITTEN under and raises + ``ValueError`` on a malformed record. Default False preserves the + v0.83.0 behaviour exactly: raw dicts, no validation, corrupt lines + skipped with a warning. Returns ------- diff --git a/plugins/claude-code/graqle/.claude-plugin/plugin.json b/plugins/claude-code/graqle/.claude-plugin/plugin.json index 6cef6142..bb66d60a 100644 --- a/plugins/claude-code/graqle/.claude-plugin/plugin.json +++ b/plugins/claude-code/graqle/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "graqle", - "version": "0.84.0", + "version": "0.84.1", "description": "GraQle dev intelligence layer: graph-powered codebase reasoning, impact analysis, and governed edits via MCP, plus governed-workflow skills and an optional governance gate hook.", "author": { "name": "Quantamix Solutions", diff --git a/plugins/codex/graqle/.codex-plugin/plugin.json b/plugins/codex/graqle/.codex-plugin/plugin.json index 92af0f59..b6b500b9 100644 --- a/plugins/codex/graqle/.codex-plugin/plugin.json +++ b/plugins/codex/graqle/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "graqle", - "version": "0.84.0", + "version": "0.84.1", "description": "GraQle dev intelligence layer: graph-powered codebase reasoning, impact analysis, and governed edits via MCP, plus governed-workflow skills.", "interface": { "displayName": "GraQle", diff --git a/pyproject.toml b/pyproject.toml index ef63a51e..5a8fec47 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,7 +18,7 @@ name = "graqle" # (first-to-finish/best-of-N) + CostAwareRouter (auto cost/latency selection) — # dual-provider autonomy. 0.77.0 = ADR-239 Stage 2 # (CheckpointProtocol + run_tests). 0.76.0 = ADR-225 G1 multi-tenant memory. -version = "0.84.0" +version = "0.84.1" description = "Persistent organisational intelligence for AI agents. Turn codebases, documents, policies and decisions into a knowledge graph so Claude Code, Cursor and Copilot reason over architecture, dependencies and prior lessons — with confidence scores and evidence. 13 LLM backends + custom, fully offline capable." readme = "README_PYPI.md" license = {text = "Apache-2.0"} @@ -165,6 +165,8 @@ dev = [ "pytest-cov>=4.0", "mypy>=1.0", "ruff>=0.1", + "import-linter>=2.0", + "hypothesis>=6.0", "coverage>=7.0", "httpx>=0.25", "boto3>=1.28", diff --git a/server.json b/server.json index b21bd5dc..6382d962 100644 --- a/server.json +++ b/server.json @@ -8,12 +8,12 @@ "url": "https://github.com/quantamixsol/graqle", "source": "github" }, - "version": "0.84.0", + "version": "0.84.1", "packages": [ { "registryType": "pypi", "identifier": "graqle", - "version": "0.84.0", + "version": "0.84.1", "transport": { "type": "stdio" } diff --git a/tests/fixtures/gate_result_golden_v0830.json b/tests/fixtures/gate_result_golden_v0830.json new file mode 100644 index 00000000..3e3afb74 --- /dev/null +++ b/tests/fixtures/gate_result_golden_v0830.json @@ -0,0 +1,1418 @@ +[ + { + "in": { + "actor": "alice", + "approved_by": "", + "diff": "+ x = 1", + "radius": 0, + "risk": "LOW" + }, + "out": { + "blocked": false, + "bypass_allowed": true, + "gate_score": 0.0, + "impact_radius": 0, + "reason": "T1: Auto-pass (low risk, low impact radius). Logged.", + "requires_approval": false, + "risk_level": "LOW", + "tier": "T1", + "warnings": [] + } + }, + { + "in": { + "actor": "alice", + "approved_by": "harish", + "diff": "+ x = 1", + "radius": 0, + "risk": "LOW" + }, + "out": { + "blocked": false, + "bypass_allowed": true, + "gate_score": 0.0, + "impact_radius": 0, + "reason": "T1: Auto-pass (low risk, low impact radius). Logged.", + "requires_approval": false, + "risk_level": "LOW", + "tier": "T1", + "warnings": [] + } + }, + { + "in": { + "actor": "alice", + "approved_by": "", + "diff": "+ password = 'synthetic-placeholder-value'", + "radius": 0, + "risk": "LOW" + }, + "out": { + "blocked": true, + "bypass_allowed": false, + "gate_score": 0.91, + "impact_radius": 0, + "reason": "T3: Explicit approval required. risk_level=LOW, impact_radius=0, gate_score=0.91. Pass approved_by='your-name' with a justification.", + "requires_approval": true, + "risk_level": "LOW", + "tier": "T3", + "warnings": [ + "Possible secret exposure: ['generic:password_assign']", + "Secret exposure elevates gate tier to T3" + ] + } + }, + { + "in": { + "actor": "alice", + "approved_by": "harish", + "diff": "+ password = 'synthetic-placeholder-value'", + "radius": 0, + "risk": "LOW" + }, + "out": { + "blocked": true, + "bypass_allowed": false, + "gate_score": 0.91, + "impact_radius": 0, + "reason": "T3: RBAC rejected \u2014 Actor 'harish' not registered in RBAC registry. Only registered actors with role 'senior'/'lead'/'admin' can approve T3.", + "requires_approval": true, + "risk_level": "LOW", + "tier": "T3", + "warnings": [ + "Possible secret exposure: ['generic:password_assign']", + "Secret exposure elevates gate tier to T3" + ] + } + }, + { + "in": { + "actor": "alice", + "approved_by": "", + "diff": "+ x = 1", + "radius": 2, + "risk": "LOW" + }, + "out": { + "blocked": false, + "bypass_allowed": true, + "gate_score": 0.0385, + "impact_radius": 2, + "reason": "T1: Auto-pass (low risk, low impact radius). Logged.", + "requires_approval": false, + "risk_level": "LOW", + "tier": "T1", + "warnings": [] + } + }, + { + "in": { + "actor": "alice", + "approved_by": "harish", + "diff": "+ x = 1", + "radius": 2, + "risk": "LOW" + }, + "out": { + "blocked": false, + "bypass_allowed": true, + "gate_score": 0.0385, + "impact_radius": 2, + "reason": "T1: Auto-pass (low risk, low impact radius). Logged.", + "requires_approval": false, + "risk_level": "LOW", + "tier": "T1", + "warnings": [] + } + }, + { + "in": { + "actor": "alice", + "approved_by": "", + "diff": "+ password = 'synthetic-placeholder-value'", + "radius": 2, + "risk": "LOW" + }, + "out": { + "blocked": true, + "bypass_allowed": false, + "gate_score": 0.91, + "impact_radius": 2, + "reason": "T3: Explicit approval required. risk_level=LOW, impact_radius=2, gate_score=0.91. Pass approved_by='your-name' with a justification.", + "requires_approval": true, + "risk_level": "LOW", + "tier": "T3", + "warnings": [ + "Possible secret exposure: ['generic:password_assign']", + "Secret exposure elevates gate tier to T3" + ] + } + }, + { + "in": { + "actor": "alice", + "approved_by": "harish", + "diff": "+ password = 'synthetic-placeholder-value'", + "radius": 2, + "risk": "LOW" + }, + "out": { + "blocked": true, + "bypass_allowed": false, + "gate_score": 0.91, + "impact_radius": 2, + "reason": "T3: RBAC rejected \u2014 Actor 'harish' not registered in RBAC registry. Only registered actors with role 'senior'/'lead'/'admin' can approve T3.", + "requires_approval": true, + "risk_level": "LOW", + "tier": "T3", + "warnings": [ + "Possible secret exposure: ['generic:password_assign']", + "Secret exposure elevates gate tier to T3" + ] + } + }, + { + "in": { + "actor": "alice", + "approved_by": "", + "diff": "+ x = 1", + "radius": 3, + "risk": "LOW" + }, + "out": { + "blocked": false, + "bypass_allowed": true, + "gate_score": 0.0577, + "impact_radius": 3, + "reason": "T2: Gate score 0.06 below threshold 0.70. Passing.", + "requires_approval": false, + "risk_level": "LOW", + "tier": "T2", + "warnings": [ + "RBAC advisory: Actor 'alice' not registered in RBAC registry. Only registered actors with role 'senior'/'lead'/'admin' can approve T2." + ] + } + }, + { + "in": { + "actor": "alice", + "approved_by": "harish", + "diff": "+ x = 1", + "radius": 3, + "risk": "LOW" + }, + "out": { + "blocked": false, + "bypass_allowed": true, + "gate_score": 0.0577, + "impact_radius": 3, + "reason": "T2: Gate score 0.06 below threshold 0.70. Passing.", + "requires_approval": false, + "risk_level": "LOW", + "tier": "T2", + "warnings": [ + "RBAC advisory: Actor 'alice' not registered in RBAC registry. Only registered actors with role 'senior'/'lead'/'admin' can approve T2." + ] + } + }, + { + "in": { + "actor": "alice", + "approved_by": "", + "diff": "+ password = 'synthetic-placeholder-value'", + "radius": 3, + "risk": "LOW" + }, + "out": { + "blocked": true, + "bypass_allowed": false, + "gate_score": 0.91, + "impact_radius": 3, + "reason": "T3: Explicit approval required. risk_level=LOW, impact_radius=3, gate_score=0.91. Pass approved_by='your-name' with a justification.", + "requires_approval": true, + "risk_level": "LOW", + "tier": "T3", + "warnings": [ + "Possible secret exposure: ['generic:password_assign']", + "Secret exposure elevates gate tier to T3" + ] + } + }, + { + "in": { + "actor": "alice", + "approved_by": "harish", + "diff": "+ password = 'synthetic-placeholder-value'", + "radius": 3, + "risk": "LOW" + }, + "out": { + "blocked": true, + "bypass_allowed": false, + "gate_score": 0.91, + "impact_radius": 3, + "reason": "T3: RBAC rejected \u2014 Actor 'harish' not registered in RBAC registry. Only registered actors with role 'senior'/'lead'/'admin' can approve T3.", + "requires_approval": true, + "risk_level": "LOW", + "tier": "T3", + "warnings": [ + "Possible secret exposure: ['generic:password_assign']", + "Secret exposure elevates gate tier to T3" + ] + } + }, + { + "in": { + "actor": "alice", + "approved_by": "", + "diff": "+ x = 1", + "radius": 15, + "risk": "LOW" + }, + "out": { + "blocked": true, + "bypass_allowed": false, + "gate_score": 0.91, + "impact_radius": 15, + "reason": "T3: Explicit approval required. risk_level=LOW, impact_radius=15, gate_score=0.91. Pass approved_by='your-name' with a justification.", + "requires_approval": true, + "risk_level": "LOW", + "tier": "T3", + "warnings": [ + "Anti-gaming: cumulative impact_radius 15 exceeds cap 10 in 24h window. T3 required regardless of individual change size." + ] + } + }, + { + "in": { + "actor": "alice", + "approved_by": "harish", + "diff": "+ x = 1", + "radius": 15, + "risk": "LOW" + }, + "out": { + "blocked": true, + "bypass_allowed": false, + "gate_score": 0.91, + "impact_radius": 15, + "reason": "T3: RBAC rejected \u2014 Actor 'harish' not registered in RBAC registry. Only registered actors with role 'senior'/'lead'/'admin' can approve T3.", + "requires_approval": true, + "risk_level": "LOW", + "tier": "T3", + "warnings": [ + "Anti-gaming: cumulative impact_radius 15 exceeds cap 10 in 24h window. T3 required regardless of individual change size." + ] + } + }, + { + "in": { + "actor": "alice", + "approved_by": "", + "diff": "+ password = 'synthetic-placeholder-value'", + "radius": 15, + "risk": "LOW" + }, + "out": { + "blocked": true, + "bypass_allowed": false, + "gate_score": 0.91, + "impact_radius": 15, + "reason": "T3: Explicit approval required. risk_level=LOW, impact_radius=15, gate_score=0.91. Pass approved_by='your-name' with a justification.", + "requires_approval": true, + "risk_level": "LOW", + "tier": "T3", + "warnings": [ + "Possible secret exposure: ['generic:password_assign']", + "Secret exposure elevates gate tier to T3", + "Anti-gaming: cumulative impact_radius 15 exceeds cap 10 in 24h window. T3 required regardless of individual change size." + ] + } + }, + { + "in": { + "actor": "alice", + "approved_by": "harish", + "diff": "+ password = 'synthetic-placeholder-value'", + "radius": 15, + "risk": "LOW" + }, + "out": { + "blocked": true, + "bypass_allowed": false, + "gate_score": 0.91, + "impact_radius": 15, + "reason": "T3: RBAC rejected \u2014 Actor 'harish' not registered in RBAC registry. Only registered actors with role 'senior'/'lead'/'admin' can approve T3.", + "requires_approval": true, + "risk_level": "LOW", + "tier": "T3", + "warnings": [ + "Possible secret exposure: ['generic:password_assign']", + "Secret exposure elevates gate tier to T3", + "Anti-gaming: cumulative impact_radius 15 exceeds cap 10 in 24h window. T3 required regardless of individual change size." + ] + } + }, + { + "in": { + "actor": "alice", + "approved_by": "", + "diff": "+ x = 1", + "radius": 0, + "risk": "MEDIUM" + }, + "out": { + "blocked": false, + "bypass_allowed": true, + "gate_score": 0.1667, + "impact_radius": 0, + "reason": "T2: Gate score 0.17 below threshold 0.70. Passing.", + "requires_approval": false, + "risk_level": "MEDIUM", + "tier": "T2", + "warnings": [ + "RBAC advisory: Actor 'alice' not registered in RBAC registry. Only registered actors with role 'senior'/'lead'/'admin' can approve T2." + ] + } + }, + { + "in": { + "actor": "alice", + "approved_by": "harish", + "diff": "+ x = 1", + "radius": 0, + "risk": "MEDIUM" + }, + "out": { + "blocked": false, + "bypass_allowed": true, + "gate_score": 0.1667, + "impact_radius": 0, + "reason": "T2: Gate score 0.17 below threshold 0.70. Passing.", + "requires_approval": false, + "risk_level": "MEDIUM", + "tier": "T2", + "warnings": [ + "RBAC advisory: Actor 'alice' not registered in RBAC registry. Only registered actors with role 'senior'/'lead'/'admin' can approve T2." + ] + } + }, + { + "in": { + "actor": "alice", + "approved_by": "", + "diff": "+ password = 'synthetic-placeholder-value'", + "radius": 0, + "risk": "MEDIUM" + }, + "out": { + "blocked": true, + "bypass_allowed": false, + "gate_score": 0.91, + "impact_radius": 0, + "reason": "T3: Explicit approval required. risk_level=MEDIUM, impact_radius=0, gate_score=0.91. Pass approved_by='your-name' with a justification.", + "requires_approval": true, + "risk_level": "MEDIUM", + "tier": "T3", + "warnings": [ + "Possible secret exposure: ['generic:password_assign']", + "Secret exposure elevates gate tier to T3" + ] + } + }, + { + "in": { + "actor": "alice", + "approved_by": "harish", + "diff": "+ password = 'synthetic-placeholder-value'", + "radius": 0, + "risk": "MEDIUM" + }, + "out": { + "blocked": true, + "bypass_allowed": false, + "gate_score": 0.91, + "impact_radius": 0, + "reason": "T3: RBAC rejected \u2014 Actor 'harish' not registered in RBAC registry. Only registered actors with role 'senior'/'lead'/'admin' can approve T3.", + "requires_approval": true, + "risk_level": "MEDIUM", + "tier": "T3", + "warnings": [ + "Possible secret exposure: ['generic:password_assign']", + "Secret exposure elevates gate tier to T3" + ] + } + }, + { + "in": { + "actor": "alice", + "approved_by": "", + "diff": "+ x = 1", + "radius": 2, + "risk": "MEDIUM" + }, + "out": { + "blocked": false, + "bypass_allowed": true, + "gate_score": 0.2051, + "impact_radius": 2, + "reason": "T2: Gate score 0.21 below threshold 0.70. Passing.", + "requires_approval": false, + "risk_level": "MEDIUM", + "tier": "T2", + "warnings": [ + "RBAC advisory: Actor 'alice' not registered in RBAC registry. Only registered actors with role 'senior'/'lead'/'admin' can approve T2." + ] + } + }, + { + "in": { + "actor": "alice", + "approved_by": "harish", + "diff": "+ x = 1", + "radius": 2, + "risk": "MEDIUM" + }, + "out": { + "blocked": false, + "bypass_allowed": true, + "gate_score": 0.2051, + "impact_radius": 2, + "reason": "T2: Gate score 0.21 below threshold 0.70. Passing.", + "requires_approval": false, + "risk_level": "MEDIUM", + "tier": "T2", + "warnings": [ + "RBAC advisory: Actor 'alice' not registered in RBAC registry. Only registered actors with role 'senior'/'lead'/'admin' can approve T2." + ] + } + }, + { + "in": { + "actor": "alice", + "approved_by": "", + "diff": "+ password = 'synthetic-placeholder-value'", + "radius": 2, + "risk": "MEDIUM" + }, + "out": { + "blocked": true, + "bypass_allowed": false, + "gate_score": 0.91, + "impact_radius": 2, + "reason": "T3: Explicit approval required. risk_level=MEDIUM, impact_radius=2, gate_score=0.91. Pass approved_by='your-name' with a justification.", + "requires_approval": true, + "risk_level": "MEDIUM", + "tier": "T3", + "warnings": [ + "Possible secret exposure: ['generic:password_assign']", + "Secret exposure elevates gate tier to T3" + ] + } + }, + { + "in": { + "actor": "alice", + "approved_by": "harish", + "diff": "+ password = 'synthetic-placeholder-value'", + "radius": 2, + "risk": "MEDIUM" + }, + "out": { + "blocked": true, + "bypass_allowed": false, + "gate_score": 0.91, + "impact_radius": 2, + "reason": "T3: RBAC rejected \u2014 Actor 'harish' not registered in RBAC registry. Only registered actors with role 'senior'/'lead'/'admin' can approve T3.", + "requires_approval": true, + "risk_level": "MEDIUM", + "tier": "T3", + "warnings": [ + "Possible secret exposure: ['generic:password_assign']", + "Secret exposure elevates gate tier to T3" + ] + } + }, + { + "in": { + "actor": "alice", + "approved_by": "", + "diff": "+ x = 1", + "radius": 3, + "risk": "MEDIUM" + }, + "out": { + "blocked": false, + "bypass_allowed": true, + "gate_score": 0.2244, + "impact_radius": 3, + "reason": "T2: Gate score 0.22 below threshold 0.70. Passing.", + "requires_approval": false, + "risk_level": "MEDIUM", + "tier": "T2", + "warnings": [ + "RBAC advisory: Actor 'alice' not registered in RBAC registry. Only registered actors with role 'senior'/'lead'/'admin' can approve T2." + ] + } + }, + { + "in": { + "actor": "alice", + "approved_by": "harish", + "diff": "+ x = 1", + "radius": 3, + "risk": "MEDIUM" + }, + "out": { + "blocked": false, + "bypass_allowed": true, + "gate_score": 0.2244, + "impact_radius": 3, + "reason": "T2: Gate score 0.22 below threshold 0.70. Passing.", + "requires_approval": false, + "risk_level": "MEDIUM", + "tier": "T2", + "warnings": [ + "RBAC advisory: Actor 'alice' not registered in RBAC registry. Only registered actors with role 'senior'/'lead'/'admin' can approve T2." + ] + } + }, + { + "in": { + "actor": "alice", + "approved_by": "", + "diff": "+ password = 'synthetic-placeholder-value'", + "radius": 3, + "risk": "MEDIUM" + }, + "out": { + "blocked": true, + "bypass_allowed": false, + "gate_score": 0.91, + "impact_radius": 3, + "reason": "T3: Explicit approval required. risk_level=MEDIUM, impact_radius=3, gate_score=0.91. Pass approved_by='your-name' with a justification.", + "requires_approval": true, + "risk_level": "MEDIUM", + "tier": "T3", + "warnings": [ + "Possible secret exposure: ['generic:password_assign']", + "Secret exposure elevates gate tier to T3" + ] + } + }, + { + "in": { + "actor": "alice", + "approved_by": "harish", + "diff": "+ password = 'synthetic-placeholder-value'", + "radius": 3, + "risk": "MEDIUM" + }, + "out": { + "blocked": true, + "bypass_allowed": false, + "gate_score": 0.91, + "impact_radius": 3, + "reason": "T3: RBAC rejected \u2014 Actor 'harish' not registered in RBAC registry. Only registered actors with role 'senior'/'lead'/'admin' can approve T3.", + "requires_approval": true, + "risk_level": "MEDIUM", + "tier": "T3", + "warnings": [ + "Possible secret exposure: ['generic:password_assign']", + "Secret exposure elevates gate tier to T3" + ] + } + }, + { + "in": { + "actor": "alice", + "approved_by": "", + "diff": "+ x = 1", + "radius": 15, + "risk": "MEDIUM" + }, + "out": { + "blocked": true, + "bypass_allowed": false, + "gate_score": 0.91, + "impact_radius": 15, + "reason": "T3: Explicit approval required. risk_level=MEDIUM, impact_radius=15, gate_score=0.91. Pass approved_by='your-name' with a justification.", + "requires_approval": true, + "risk_level": "MEDIUM", + "tier": "T3", + "warnings": [ + "Anti-gaming: cumulative impact_radius 15 exceeds cap 10 in 24h window. T3 required regardless of individual change size." + ] + } + }, + { + "in": { + "actor": "alice", + "approved_by": "harish", + "diff": "+ x = 1", + "radius": 15, + "risk": "MEDIUM" + }, + "out": { + "blocked": true, + "bypass_allowed": false, + "gate_score": 0.91, + "impact_radius": 15, + "reason": "T3: RBAC rejected \u2014 Actor 'harish' not registered in RBAC registry. Only registered actors with role 'senior'/'lead'/'admin' can approve T3.", + "requires_approval": true, + "risk_level": "MEDIUM", + "tier": "T3", + "warnings": [ + "Anti-gaming: cumulative impact_radius 15 exceeds cap 10 in 24h window. T3 required regardless of individual change size." + ] + } + }, + { + "in": { + "actor": "alice", + "approved_by": "", + "diff": "+ password = 'synthetic-placeholder-value'", + "radius": 15, + "risk": "MEDIUM" + }, + "out": { + "blocked": true, + "bypass_allowed": false, + "gate_score": 0.91, + "impact_radius": 15, + "reason": "T3: Explicit approval required. risk_level=MEDIUM, impact_radius=15, gate_score=0.91. Pass approved_by='your-name' with a justification.", + "requires_approval": true, + "risk_level": "MEDIUM", + "tier": "T3", + "warnings": [ + "Possible secret exposure: ['generic:password_assign']", + "Secret exposure elevates gate tier to T3", + "Anti-gaming: cumulative impact_radius 15 exceeds cap 10 in 24h window. T3 required regardless of individual change size." + ] + } + }, + { + "in": { + "actor": "alice", + "approved_by": "harish", + "diff": "+ password = 'synthetic-placeholder-value'", + "radius": 15, + "risk": "MEDIUM" + }, + "out": { + "blocked": true, + "bypass_allowed": false, + "gate_score": 0.91, + "impact_radius": 15, + "reason": "T3: RBAC rejected \u2014 Actor 'harish' not registered in RBAC registry. Only registered actors with role 'senior'/'lead'/'admin' can approve T3.", + "requires_approval": true, + "risk_level": "MEDIUM", + "tier": "T3", + "warnings": [ + "Possible secret exposure: ['generic:password_assign']", + "Secret exposure elevates gate tier to T3", + "Anti-gaming: cumulative impact_radius 15 exceeds cap 10 in 24h window. T3 required regardless of individual change size." + ] + } + }, + { + "in": { + "actor": "alice", + "approved_by": "", + "diff": "+ x = 1", + "radius": 0, + "risk": "HIGH" + }, + "out": { + "blocked": true, + "bypass_allowed": false, + "gate_score": 0.3333, + "impact_radius": 0, + "reason": "T3: Explicit approval required. risk_level=HIGH, impact_radius=0, gate_score=0.33. Pass approved_by='your-name' with a justification.", + "requires_approval": true, + "risk_level": "HIGH", + "tier": "T3", + "warnings": [] + } + }, + { + "in": { + "actor": "alice", + "approved_by": "harish", + "diff": "+ x = 1", + "radius": 0, + "risk": "HIGH" + }, + "out": { + "blocked": true, + "bypass_allowed": false, + "gate_score": 0.3333, + "impact_radius": 0, + "reason": "T3: RBAC rejected \u2014 Actor 'harish' not registered in RBAC registry. Only registered actors with role 'senior'/'lead'/'admin' can approve T3.", + "requires_approval": true, + "risk_level": "HIGH", + "tier": "T3", + "warnings": [] + } + }, + { + "in": { + "actor": "alice", + "approved_by": "", + "diff": "+ password = 'synthetic-placeholder-value'", + "radius": 0, + "risk": "HIGH" + }, + "out": { + "blocked": true, + "bypass_allowed": false, + "gate_score": 0.91, + "impact_radius": 0, + "reason": "T3: Explicit approval required. risk_level=HIGH, impact_radius=0, gate_score=0.91. Pass approved_by='your-name' with a justification.", + "requires_approval": true, + "risk_level": "HIGH", + "tier": "T3", + "warnings": [ + "Possible secret exposure: ['generic:password_assign']", + "Secret exposure elevates gate tier to T3" + ] + } + }, + { + "in": { + "actor": "alice", + "approved_by": "harish", + "diff": "+ password = 'synthetic-placeholder-value'", + "radius": 0, + "risk": "HIGH" + }, + "out": { + "blocked": true, + "bypass_allowed": false, + "gate_score": 0.91, + "impact_radius": 0, + "reason": "T3: RBAC rejected \u2014 Actor 'harish' not registered in RBAC registry. Only registered actors with role 'senior'/'lead'/'admin' can approve T3.", + "requires_approval": true, + "risk_level": "HIGH", + "tier": "T3", + "warnings": [ + "Possible secret exposure: ['generic:password_assign']", + "Secret exposure elevates gate tier to T3" + ] + } + }, + { + "in": { + "actor": "alice", + "approved_by": "", + "diff": "+ x = 1", + "radius": 2, + "risk": "HIGH" + }, + "out": { + "blocked": true, + "bypass_allowed": false, + "gate_score": 0.3718, + "impact_radius": 2, + "reason": "T3: Explicit approval required. risk_level=HIGH, impact_radius=2, gate_score=0.37. Pass approved_by='your-name' with a justification.", + "requires_approval": true, + "risk_level": "HIGH", + "tier": "T3", + "warnings": [] + } + }, + { + "in": { + "actor": "alice", + "approved_by": "harish", + "diff": "+ x = 1", + "radius": 2, + "risk": "HIGH" + }, + "out": { + "blocked": true, + "bypass_allowed": false, + "gate_score": 0.3718, + "impact_radius": 2, + "reason": "T3: RBAC rejected \u2014 Actor 'harish' not registered in RBAC registry. Only registered actors with role 'senior'/'lead'/'admin' can approve T3.", + "requires_approval": true, + "risk_level": "HIGH", + "tier": "T3", + "warnings": [] + } + }, + { + "in": { + "actor": "alice", + "approved_by": "", + "diff": "+ password = 'synthetic-placeholder-value'", + "radius": 2, + "risk": "HIGH" + }, + "out": { + "blocked": true, + "bypass_allowed": false, + "gate_score": 0.91, + "impact_radius": 2, + "reason": "T3: Explicit approval required. risk_level=HIGH, impact_radius=2, gate_score=0.91. Pass approved_by='your-name' with a justification.", + "requires_approval": true, + "risk_level": "HIGH", + "tier": "T3", + "warnings": [ + "Possible secret exposure: ['generic:password_assign']", + "Secret exposure elevates gate tier to T3" + ] + } + }, + { + "in": { + "actor": "alice", + "approved_by": "harish", + "diff": "+ password = 'synthetic-placeholder-value'", + "radius": 2, + "risk": "HIGH" + }, + "out": { + "blocked": true, + "bypass_allowed": false, + "gate_score": 0.91, + "impact_radius": 2, + "reason": "T3: RBAC rejected \u2014 Actor 'harish' not registered in RBAC registry. Only registered actors with role 'senior'/'lead'/'admin' can approve T3.", + "requires_approval": true, + "risk_level": "HIGH", + "tier": "T3", + "warnings": [ + "Possible secret exposure: ['generic:password_assign']", + "Secret exposure elevates gate tier to T3" + ] + } + }, + { + "in": { + "actor": "alice", + "approved_by": "", + "diff": "+ x = 1", + "radius": 3, + "risk": "HIGH" + }, + "out": { + "blocked": true, + "bypass_allowed": false, + "gate_score": 0.391, + "impact_radius": 3, + "reason": "T3: Explicit approval required. risk_level=HIGH, impact_radius=3, gate_score=0.39. Pass approved_by='your-name' with a justification.", + "requires_approval": true, + "risk_level": "HIGH", + "tier": "T3", + "warnings": [] + } + }, + { + "in": { + "actor": "alice", + "approved_by": "harish", + "diff": "+ x = 1", + "radius": 3, + "risk": "HIGH" + }, + "out": { + "blocked": true, + "bypass_allowed": false, + "gate_score": 0.391, + "impact_radius": 3, + "reason": "T3: RBAC rejected \u2014 Actor 'harish' not registered in RBAC registry. Only registered actors with role 'senior'/'lead'/'admin' can approve T3.", + "requires_approval": true, + "risk_level": "HIGH", + "tier": "T3", + "warnings": [] + } + }, + { + "in": { + "actor": "alice", + "approved_by": "", + "diff": "+ password = 'synthetic-placeholder-value'", + "radius": 3, + "risk": "HIGH" + }, + "out": { + "blocked": true, + "bypass_allowed": false, + "gate_score": 0.91, + "impact_radius": 3, + "reason": "T3: Explicit approval required. risk_level=HIGH, impact_radius=3, gate_score=0.91. Pass approved_by='your-name' with a justification.", + "requires_approval": true, + "risk_level": "HIGH", + "tier": "T3", + "warnings": [ + "Possible secret exposure: ['generic:password_assign']", + "Secret exposure elevates gate tier to T3" + ] + } + }, + { + "in": { + "actor": "alice", + "approved_by": "harish", + "diff": "+ password = 'synthetic-placeholder-value'", + "radius": 3, + "risk": "HIGH" + }, + "out": { + "blocked": true, + "bypass_allowed": false, + "gate_score": 0.91, + "impact_radius": 3, + "reason": "T3: RBAC rejected \u2014 Actor 'harish' not registered in RBAC registry. Only registered actors with role 'senior'/'lead'/'admin' can approve T3.", + "requires_approval": true, + "risk_level": "HIGH", + "tier": "T3", + "warnings": [ + "Possible secret exposure: ['generic:password_assign']", + "Secret exposure elevates gate tier to T3" + ] + } + }, + { + "in": { + "actor": "alice", + "approved_by": "", + "diff": "+ x = 1", + "radius": 15, + "risk": "HIGH" + }, + "out": { + "blocked": true, + "bypass_allowed": false, + "gate_score": 0.91, + "impact_radius": 15, + "reason": "T3: Explicit approval required. risk_level=HIGH, impact_radius=15, gate_score=0.91. Pass approved_by='your-name' with a justification.", + "requires_approval": true, + "risk_level": "HIGH", + "tier": "T3", + "warnings": [ + "Anti-gaming: cumulative impact_radius 15 exceeds cap 10 in 24h window. T3 required regardless of individual change size." + ] + } + }, + { + "in": { + "actor": "alice", + "approved_by": "harish", + "diff": "+ x = 1", + "radius": 15, + "risk": "HIGH" + }, + "out": { + "blocked": true, + "bypass_allowed": false, + "gate_score": 0.91, + "impact_radius": 15, + "reason": "T3: RBAC rejected \u2014 Actor 'harish' not registered in RBAC registry. Only registered actors with role 'senior'/'lead'/'admin' can approve T3.", + "requires_approval": true, + "risk_level": "HIGH", + "tier": "T3", + "warnings": [ + "Anti-gaming: cumulative impact_radius 15 exceeds cap 10 in 24h window. T3 required regardless of individual change size." + ] + } + }, + { + "in": { + "actor": "alice", + "approved_by": "", + "diff": "+ password = 'synthetic-placeholder-value'", + "radius": 15, + "risk": "HIGH" + }, + "out": { + "blocked": true, + "bypass_allowed": false, + "gate_score": 0.91, + "impact_radius": 15, + "reason": "T3: Explicit approval required. risk_level=HIGH, impact_radius=15, gate_score=0.91. Pass approved_by='your-name' with a justification.", + "requires_approval": true, + "risk_level": "HIGH", + "tier": "T3", + "warnings": [ + "Possible secret exposure: ['generic:password_assign']", + "Secret exposure elevates gate tier to T3", + "Anti-gaming: cumulative impact_radius 15 exceeds cap 10 in 24h window. T3 required regardless of individual change size." + ] + } + }, + { + "in": { + "actor": "alice", + "approved_by": "harish", + "diff": "+ password = 'synthetic-placeholder-value'", + "radius": 15, + "risk": "HIGH" + }, + "out": { + "blocked": true, + "bypass_allowed": false, + "gate_score": 0.91, + "impact_radius": 15, + "reason": "T3: RBAC rejected \u2014 Actor 'harish' not registered in RBAC registry. Only registered actors with role 'senior'/'lead'/'admin' can approve T3.", + "requires_approval": true, + "risk_level": "HIGH", + "tier": "T3", + "warnings": [ + "Possible secret exposure: ['generic:password_assign']", + "Secret exposure elevates gate tier to T3", + "Anti-gaming: cumulative impact_radius 15 exceeds cap 10 in 24h window. T3 required regardless of individual change size." + ] + } + }, + { + "in": { + "actor": "alice", + "approved_by": "", + "diff": "+ x = 1", + "radius": 0, + "risk": "CRITICAL" + }, + "out": { + "blocked": true, + "bypass_allowed": false, + "gate_score": 0.5, + "impact_radius": 0, + "reason": "T3: Explicit approval required. risk_level=CRITICAL, impact_radius=0, gate_score=0.50. Pass approved_by='your-name' with a justification.", + "requires_approval": true, + "risk_level": "CRITICAL", + "tier": "T3", + "warnings": [] + } + }, + { + "in": { + "actor": "alice", + "approved_by": "harish", + "diff": "+ x = 1", + "radius": 0, + "risk": "CRITICAL" + }, + "out": { + "blocked": true, + "bypass_allowed": false, + "gate_score": 0.5, + "impact_radius": 0, + "reason": "T3: RBAC rejected \u2014 Actor 'harish' not registered in RBAC registry. Only registered actors with role 'senior'/'lead'/'admin' can approve T3.", + "requires_approval": true, + "risk_level": "CRITICAL", + "tier": "T3", + "warnings": [] + } + }, + { + "in": { + "actor": "alice", + "approved_by": "", + "diff": "+ password = 'synthetic-placeholder-value'", + "radius": 0, + "risk": "CRITICAL" + }, + "out": { + "blocked": true, + "bypass_allowed": false, + "gate_score": 0.91, + "impact_radius": 0, + "reason": "T3: Explicit approval required. risk_level=CRITICAL, impact_radius=0, gate_score=0.91. Pass approved_by='your-name' with a justification.", + "requires_approval": true, + "risk_level": "CRITICAL", + "tier": "T3", + "warnings": [ + "Possible secret exposure: ['generic:password_assign']", + "Secret exposure elevates gate tier to T3" + ] + } + }, + { + "in": { + "actor": "alice", + "approved_by": "harish", + "diff": "+ password = 'synthetic-placeholder-value'", + "radius": 0, + "risk": "CRITICAL" + }, + "out": { + "blocked": true, + "bypass_allowed": false, + "gate_score": 0.91, + "impact_radius": 0, + "reason": "T3: RBAC rejected \u2014 Actor 'harish' not registered in RBAC registry. Only registered actors with role 'senior'/'lead'/'admin' can approve T3.", + "requires_approval": true, + "risk_level": "CRITICAL", + "tier": "T3", + "warnings": [ + "Possible secret exposure: ['generic:password_assign']", + "Secret exposure elevates gate tier to T3" + ] + } + }, + { + "in": { + "actor": "alice", + "approved_by": "", + "diff": "+ x = 1", + "radius": 2, + "risk": "CRITICAL" + }, + "out": { + "blocked": true, + "bypass_allowed": false, + "gate_score": 0.5385, + "impact_radius": 2, + "reason": "T3: Explicit approval required. risk_level=CRITICAL, impact_radius=2, gate_score=0.54. Pass approved_by='your-name' with a justification.", + "requires_approval": true, + "risk_level": "CRITICAL", + "tier": "T3", + "warnings": [] + } + }, + { + "in": { + "actor": "alice", + "approved_by": "harish", + "diff": "+ x = 1", + "radius": 2, + "risk": "CRITICAL" + }, + "out": { + "blocked": true, + "bypass_allowed": false, + "gate_score": 0.5385, + "impact_radius": 2, + "reason": "T3: RBAC rejected \u2014 Actor 'harish' not registered in RBAC registry. Only registered actors with role 'senior'/'lead'/'admin' can approve T3.", + "requires_approval": true, + "risk_level": "CRITICAL", + "tier": "T3", + "warnings": [] + } + }, + { + "in": { + "actor": "alice", + "approved_by": "", + "diff": "+ password = 'synthetic-placeholder-value'", + "radius": 2, + "risk": "CRITICAL" + }, + "out": { + "blocked": true, + "bypass_allowed": false, + "gate_score": 0.91, + "impact_radius": 2, + "reason": "T3: Explicit approval required. risk_level=CRITICAL, impact_radius=2, gate_score=0.91. Pass approved_by='your-name' with a justification.", + "requires_approval": true, + "risk_level": "CRITICAL", + "tier": "T3", + "warnings": [ + "Possible secret exposure: ['generic:password_assign']", + "Secret exposure elevates gate tier to T3" + ] + } + }, + { + "in": { + "actor": "alice", + "approved_by": "harish", + "diff": "+ password = 'synthetic-placeholder-value'", + "radius": 2, + "risk": "CRITICAL" + }, + "out": { + "blocked": true, + "bypass_allowed": false, + "gate_score": 0.91, + "impact_radius": 2, + "reason": "T3: RBAC rejected \u2014 Actor 'harish' not registered in RBAC registry. Only registered actors with role 'senior'/'lead'/'admin' can approve T3.", + "requires_approval": true, + "risk_level": "CRITICAL", + "tier": "T3", + "warnings": [ + "Possible secret exposure: ['generic:password_assign']", + "Secret exposure elevates gate tier to T3" + ] + } + }, + { + "in": { + "actor": "alice", + "approved_by": "", + "diff": "+ x = 1", + "radius": 3, + "risk": "CRITICAL" + }, + "out": { + "blocked": true, + "bypass_allowed": false, + "gate_score": 0.5577, + "impact_radius": 3, + "reason": "T3: Explicit approval required. risk_level=CRITICAL, impact_radius=3, gate_score=0.56. Pass approved_by='your-name' with a justification.", + "requires_approval": true, + "risk_level": "CRITICAL", + "tier": "T3", + "warnings": [] + } + }, + { + "in": { + "actor": "alice", + "approved_by": "harish", + "diff": "+ x = 1", + "radius": 3, + "risk": "CRITICAL" + }, + "out": { + "blocked": true, + "bypass_allowed": false, + "gate_score": 0.5577, + "impact_radius": 3, + "reason": "T3: RBAC rejected \u2014 Actor 'harish' not registered in RBAC registry. Only registered actors with role 'senior'/'lead'/'admin' can approve T3.", + "requires_approval": true, + "risk_level": "CRITICAL", + "tier": "T3", + "warnings": [] + } + }, + { + "in": { + "actor": "alice", + "approved_by": "", + "diff": "+ password = 'synthetic-placeholder-value'", + "radius": 3, + "risk": "CRITICAL" + }, + "out": { + "blocked": true, + "bypass_allowed": false, + "gate_score": 0.91, + "impact_radius": 3, + "reason": "T3: Explicit approval required. risk_level=CRITICAL, impact_radius=3, gate_score=0.91. Pass approved_by='your-name' with a justification.", + "requires_approval": true, + "risk_level": "CRITICAL", + "tier": "T3", + "warnings": [ + "Possible secret exposure: ['generic:password_assign']", + "Secret exposure elevates gate tier to T3" + ] + } + }, + { + "in": { + "actor": "alice", + "approved_by": "harish", + "diff": "+ password = 'synthetic-placeholder-value'", + "radius": 3, + "risk": "CRITICAL" + }, + "out": { + "blocked": true, + "bypass_allowed": false, + "gate_score": 0.91, + "impact_radius": 3, + "reason": "T3: RBAC rejected \u2014 Actor 'harish' not registered in RBAC registry. Only registered actors with role 'senior'/'lead'/'admin' can approve T3.", + "requires_approval": true, + "risk_level": "CRITICAL", + "tier": "T3", + "warnings": [ + "Possible secret exposure: ['generic:password_assign']", + "Secret exposure elevates gate tier to T3" + ] + } + }, + { + "in": { + "actor": "alice", + "approved_by": "", + "diff": "+ x = 1", + "radius": 15, + "risk": "CRITICAL" + }, + "out": { + "blocked": true, + "bypass_allowed": false, + "gate_score": 0.91, + "impact_radius": 15, + "reason": "T3: Explicit approval required. risk_level=CRITICAL, impact_radius=15, gate_score=0.91. Pass approved_by='your-name' with a justification.", + "requires_approval": true, + "risk_level": "CRITICAL", + "tier": "T3", + "warnings": [ + "Anti-gaming: cumulative impact_radius 15 exceeds cap 10 in 24h window. T3 required regardless of individual change size." + ] + } + }, + { + "in": { + "actor": "alice", + "approved_by": "harish", + "diff": "+ x = 1", + "radius": 15, + "risk": "CRITICAL" + }, + "out": { + "blocked": true, + "bypass_allowed": false, + "gate_score": 0.91, + "impact_radius": 15, + "reason": "T3: RBAC rejected \u2014 Actor 'harish' not registered in RBAC registry. Only registered actors with role 'senior'/'lead'/'admin' can approve T3.", + "requires_approval": true, + "risk_level": "CRITICAL", + "tier": "T3", + "warnings": [ + "Anti-gaming: cumulative impact_radius 15 exceeds cap 10 in 24h window. T3 required regardless of individual change size." + ] + } + }, + { + "in": { + "actor": "alice", + "approved_by": "", + "diff": "+ password = 'synthetic-placeholder-value'", + "radius": 15, + "risk": "CRITICAL" + }, + "out": { + "blocked": true, + "bypass_allowed": false, + "gate_score": 0.91, + "impact_radius": 15, + "reason": "T3: Explicit approval required. risk_level=CRITICAL, impact_radius=15, gate_score=0.91. Pass approved_by='your-name' with a justification.", + "requires_approval": true, + "risk_level": "CRITICAL", + "tier": "T3", + "warnings": [ + "Possible secret exposure: ['generic:password_assign']", + "Secret exposure elevates gate tier to T3", + "Anti-gaming: cumulative impact_radius 15 exceeds cap 10 in 24h window. T3 required regardless of individual change size." + ] + } + }, + { + "in": { + "actor": "alice", + "approved_by": "harish", + "diff": "+ password = 'synthetic-placeholder-value'", + "radius": 15, + "risk": "CRITICAL" + }, + "out": { + "blocked": true, + "bypass_allowed": false, + "gate_score": 0.91, + "impact_radius": 15, + "reason": "T3: RBAC rejected \u2014 Actor 'harish' not registered in RBAC registry. Only registered actors with role 'senior'/'lead'/'admin' can approve T3.", + "requires_approval": true, + "risk_level": "CRITICAL", + "tier": "T3", + "warnings": [ + "Possible secret exposure: ['generic:password_assign']", + "Secret exposure elevates gate tier to T3", + "Anti-gaming: cumulative impact_radius 15 exceeds cap 10 in 24h window. T3 required regardless of individual change size." + ] + } + } +] diff --git a/tests/test_activation/test_vector_index_missing.py b/tests/test_activation/test_vector_index_missing.py new file mode 100644 index 00000000..5882019e --- /dev/null +++ b/tests/test_activation/test_vector_index_missing.py @@ -0,0 +1,269 @@ +"""CR-012 PR-012d (ruling N7) — AC-23: activation must fail VISIBLY. + +The defect: on a neo4j-first project whose substrate has no vector index (or +zero chunk embeddings), ``vector_search`` raised deep inside the driver, the +activator caught the bare ``Exception``, logged at WARNING, and returned the +whole graph. Every retrieval-dependent answer was then produced from a +substrate that could not activate — with no error, and no signal in +``graph_health`` or ``graq doctor``. + +These tests assert OBSERVABLE BEHAVIOUR (raise / log level / reported mode), +never source strings. + +NOTE ON LOCATION: this file lives in ``tests/test_activation/`` and NOT in +``test_cypher_activation.py`` because that module is in the ci.yml +``--ignore`` list — AC-23 tests placed there would never run in CI. +""" + +from __future__ import annotations + +import logging +from typing import Any + +import pytest + +from graqle.activation.cypher_activation import CypherActivation +from graqle.activation.multi_signal import MultiSignalActivation +from graqle.core.exceptions import GraqleError, VectorIndexMissingError + + +class _Graph: + def __init__(self, n: int = 5) -> None: + self.nodes = {f"node{i}": object() for i in range(n)} + + +class _Embedder: + def embed(self, query: str) -> list[float]: # noqa: ARG002 + return [0.1] * 1024 + + +class _FailingEmbedder: + def embed(self, query: str) -> list[float]: # noqa: ARG002 + raise RuntimeError("embedding backend unreachable") + + +_UNUSABLE_PROBE = { + "index_name": "cogni_chunk_embedding_index", + "database": "research", + "index_state": "NOT_FOUND", + "chunks_total": 3722, + "chunks_embedded": 0, + "usable": False, +} + + +class _Connector: + """Connector whose vector_search fails the way a missing index does.""" + + def __init__(self, mode: str = "raise") -> None: + self._mode = mode + + def vector_search(self, **kwargs: Any) -> list[tuple[str, float]]: # noqa: ARG002 + if self._mode == "raise": + raise RuntimeError("no such vector index") + return [] # index present but matched nothing + + def probe_vector_index(self) -> dict[str, Any]: + return dict(_UNUSABLE_PROBE) + + +class _HealthyConnector: + def vector_search(self, **kwargs: Any) -> list[tuple[str, float]]: # noqa: ARG002 + return [("node1", 0.91), ("node2", 0.77)] + + def probe_vector_index(self) -> dict[str, Any]: + return {**_UNUSABLE_PROBE, "index_state": "ONLINE", + "chunks_embedded": 3722, "usable": True} + + +def _activators(strict: bool = False) -> list[Any]: + """Both activators — the spec requires `strict` on BOTH.""" + return [ + CypherActivation(connector=_Connector(), embedding_engine=_Embedder(), + max_nodes=3, strict=strict), + MultiSignalActivation(connector=_Connector(), embedding_engine=_Embedder(), + max_nodes=3, strict=strict), + ] + + +class TestStrictRaises: + """AC-23: ``strict=True`` ⇒ VectorIndexMissingError.""" + + @pytest.mark.parametrize("activator", _activators(strict=True)) + def test_strict_constructor_raises(self, activator: Any) -> None: + with pytest.raises(VectorIndexMissingError): + activator.activate(_Graph(), "any query") + + @pytest.mark.parametrize("activator", _activators()) + def test_strict_per_call_override_raises(self, activator: Any) -> None: + with pytest.raises(VectorIndexMissingError): + activator.activate(_Graph(), "any query", strict=True) + + @pytest.mark.parametrize("activator", _activators()) + def test_per_call_strict_does_not_leak(self, activator: Any) -> None: + # A strict call must not permanently flip the instance. + with pytest.raises(VectorIndexMissingError): + activator.activate(_Graph(), "q", strict=True) + activator.activate(_Graph(), "q") # must NOT raise + + def test_error_is_a_graqle_error(self) -> None: + assert issubclass(VectorIndexMissingError, GraqleError) + + def test_error_carries_diagnostic_state(self) -> None: + activator = CypherActivation(connector=_Connector(), + embedding_engine=_Embedder(), strict=True) + with pytest.raises(VectorIndexMissingError) as caught: + activator.activate(_Graph(), "q") + err = caught.value + assert err.index_name == "cogni_chunk_embedding_index" + assert err.chunks_total == 3722 + assert err.chunks_embedded == 0 + assert "graq doctor" in str(err) + + def test_zero_hits_also_raises_under_strict(self) -> None: + # An index that matches nothing is indistinguishable from a missing one + # to a bare except — strict must surface both. + activator = CypherActivation(connector=_Connector(mode="empty"), + embedding_engine=_Embedder(), strict=True) + with pytest.raises(VectorIndexMissingError): + activator.activate(_Graph(), "q") + + def test_embedding_failure_also_raises_under_strict(self) -> None: + activator = CypherActivation(connector=_Connector(), + embedding_engine=_FailingEmbedder(), strict=True) + with pytest.raises(VectorIndexMissingError): + activator.activate(_Graph(), "q") + + +class TestDefaultDegradesButLoudly: + """Default preserves v0.83.0 behaviour EXCEPT the log level + health fields.""" + + @pytest.mark.parametrize("activator", _activators()) + def test_default_returns_fallback_without_raising(self, activator: Any) -> None: + result = activator.activate(_Graph(), "q") + assert result == ["node0", "node1", "node2"] + + @pytest.mark.parametrize("activator", _activators()) + def test_default_path_logs_at_warning_not_error( + self, activator: Any, caplog: pytest.LogCaptureFixture + ) -> None: + # Sentinel B1: log LEVEL is an observable contract that alerting keys + # on. `strict` defaults to False and must preserve v0.83.0's WARNING, + # so a graceful fallback does not start paging operators. What ruling + # N7 adds on this path is the CONTENT (index state + coverage), not a + # level escalation. + with caplog.at_level(logging.WARNING): + activator.activate(_Graph(), "q") + degraded = [r for r in caplog.records if "DEGRADED" in r.message] + assert degraded, "a degraded activation must be logged" + assert all(r.levelno == logging.WARNING for r in degraded) + + @pytest.mark.parametrize("activator", _activators()) + def test_degraded_log_names_index_state_and_coverage( + self, activator: Any, caplog: pytest.LogCaptureFixture + ) -> None: + # The diagnostic content ruling N7 asked for, on the DEFAULT path. + with caplog.at_level(logging.WARNING): + activator.activate(_Graph(), "q") + text = " ".join(r.getMessage() for r in caplog.records if "DEGRADED" in r.message) + assert "NOT_FOUND" in text + assert "0/3722" in text + + def test_strict_path_logs_at_error(self, caplog: pytest.LogCaptureFixture) -> None: + # An opt-in strict caller has asked to treat this as fatal, so ERROR + # is appropriate there — and only there. + activator = CypherActivation(connector=_Connector(), + embedding_engine=_Embedder(), strict=True) + with caplog.at_level(logging.WARNING): + with pytest.raises(VectorIndexMissingError): + activator.activate(_Graph(), "q") + degraded = [r for r in caplog.records if "DEGRADED" in r.message] + assert degraded and all(r.levelno == logging.ERROR for r in degraded) + + +class TestProbeIsRateLimited: + """Sentinel M1: _degrade runs on an ALREADY-failing path.""" + + def test_probe_is_not_called_on_every_degradation(self) -> None: + class _CountingConnector(_Connector): + def __init__(self) -> None: + super().__init__() + self.probe_calls = 0 + + def probe_vector_index(self) -> dict[str, Any]: + self.probe_calls += 1 + return dict(_UNUSABLE_PROBE) + + conn = _CountingConnector() + activator = CypherActivation(connector=conn, embedding_engine=_Embedder(), + max_nodes=3) + for _ in range(5): + activator.activate(_Graph(), "q") + # A retry storm must not issue one live round-trip per failure. + assert conn.probe_calls == 1, ( + f"probe hit the substrate {conn.probe_calls}x across 5 degradations" + ) + + def test_cached_probe_still_reports_coverage(self) -> None: + activator = CypherActivation(connector=_Connector(), + embedding_engine=_Embedder(), max_nodes=3) + activator.activate(_Graph(), "q") + activator.activate(_Graph(), "q") + assert activator.chunks_unembedded == 3722 + + @pytest.mark.parametrize("activator", _activators()) + def test_activation_mode_reports_the_degradation(self, activator: Any) -> None: + activator.activate(_Graph(), "q") + assert activator.activation_mode == "keyword_fallback" + + @pytest.mark.parametrize("activator", _activators()) + def test_chunks_unembedded_is_surfaced(self, activator: Any) -> None: + activator.activate(_Graph(), "q") + assert activator.chunks_unembedded == 3722 + + +class TestHealthyPathUnchanged: + """A working substrate must behave exactly as before.""" + + def test_semantic_mode_reported(self) -> None: + activator = CypherActivation(connector=_HealthyConnector(), + embedding_engine=_Embedder(), max_nodes=5) + activated = activator.activate(_Graph(), "q") + assert activated == ["node1", "node2"] + assert activator.activation_mode == "semantic" + + def test_healthy_path_logs_no_error(self, caplog: pytest.LogCaptureFixture) -> None: + activator = CypherActivation(connector=_HealthyConnector(), + embedding_engine=_Embedder(), max_nodes=5) + with caplog.at_level(logging.ERROR): + activator.activate(_Graph(), "q") + assert not [r for r in caplog.records if r.levelno >= logging.ERROR] + + def test_strict_does_not_raise_on_a_healthy_substrate(self) -> None: + activator = CypherActivation(connector=_HealthyConnector(), + embedding_engine=_Embedder(), strict=True) + assert activator.activate(_Graph(), "q") == ["node1", "node2"] + + +class TestProbeNeverRaises: + """The probe is a diagnostic — it must never become a new failure mode.""" + + def test_activation_still_degrades_when_probe_itself_fails(self) -> None: + class _BadProbe(_Connector): + def probe_vector_index(self) -> dict[str, Any]: + raise RuntimeError("probe exploded") + + activator = CypherActivation(connector=_BadProbe(), + embedding_engine=_Embedder(), max_nodes=3) + assert activator.activate(_Graph(), "q") == ["node0", "node1", "node2"] + assert activator.activation_mode == "keyword_fallback" + + def test_connector_without_a_probe_still_degrades(self) -> None: + class _Legacy: + def vector_search(self, **kwargs: Any) -> list[tuple[str, float]]: # noqa: ARG002 + raise RuntimeError("boom") + + activator = CypherActivation(connector=_Legacy(), + embedding_engine=_Embedder(), max_nodes=3) + assert activator.activate(_Graph(), "q") == ["node0", "node1", "node2"] + assert activator.activation_mode == "keyword_fallback" diff --git a/tests/test_assurance/test_gateverdictref_shape_parity.py b/tests/test_assurance/test_gateverdictref_shape_parity.py new file mode 100644 index 00000000..eef3bd84 --- /dev/null +++ b/tests/test_assurance/test_gateverdictref_shape_parity.py @@ -0,0 +1,54 @@ +"""CR-012 / PR-012b OQ-3 — `GateVerdictRef` shape parity. + +The model is DUPLICATED across the assurance/governance boundary so that +`graqle.governance` never imports `graqle.assurance` (AC-21). Duplication is a +drift risk, so this test is the drift DETECTOR the CR relies on: the two +definitions must stay field-identical, with one deliberate difference — +`outcome` is the `GateOutcome` enum on the assurance side and a plain `str` on +the trace side. + +OQ-3 is open with the Research Team: a shared leaf module would make drift +impossible by construction rather than detectable after the fact. +""" + +from __future__ import annotations + +from graqle.assurance.verdict import GateVerdictRef as AssuranceRef +from graqle.governance.trace_schema import GateVerdictRef as TraceRef + + +def _shape(model: type) -> dict[str, object]: + return {name: field.is_required() for name, field in model.model_fields.items()} + + +class TestShapeParity: + def test_same_field_names_in_the_same_order(self) -> None: + assert list(AssuranceRef.model_fields) == list(TraceRef.model_fields) + + def test_same_requiredness(self) -> None: + assert _shape(AssuranceRef) == _shape(TraceRef) + + def test_same_defaults(self) -> None: + for name, field in AssuranceRef.model_fields.items(): + assert field.default == TraceRef.model_fields[name].default, name + + def test_both_forbid_unknown_fields(self) -> None: + assert AssuranceRef.model_config.get("extra") == "forbid" + assert TraceRef.model_config.get("extra") == "forbid" + + def test_outcome_is_str_typed_on_the_trace_side(self) -> None: + # The deliberate difference: trace_schema must not import GateOutcome. + assert TraceRef.model_fields["outcome"].annotation is str + + def test_a_verdict_ref_crosses_the_boundary_by_value(self) -> None: + payload = { + "verdict_schema_version": "1", + "outcome": "REJECT", + "reason_codes": ["DAG-HG01-CC"], + "decisive_rule": "HG-01", + "cap_applied": True, + "inputs_hash": "sha256:" + "a" * 64, + "config_version": "sha256:b", + "evaluation_error_count": 0, + } + assert AssuranceRef(**payload).model_dump() == TraceRef(**payload).model_dump() diff --git a/tests/test_assurance/test_import_direction.py b/tests/test_assurance/test_import_direction.py new file mode 100644 index 00000000..af60c1c8 --- /dev/null +++ b/tests/test_assurance/test_import_direction.py @@ -0,0 +1,56 @@ +"""CR-012 AC-21 / ruling N5 — the assurance layer's dependency direction. + +`.importlinter` catches EAGER, module-level violations statically. This module +asserts the invariant N5 actually specifies: at RUNTIME, with the flag off, +importing a pre-existing module must not pull `graqle.assurance` into +`sys.modules`. The three `config/settings.py` references are inside function and +property bodies, so the static graph sees edges that never execute at import. +""" + +from __future__ import annotations + +import subprocess +import sys + +_PROBE = """ +import sys +import {module} +leaked = sorted(m for m in sys.modules if m.startswith("graqle.assurance")) +print(",".join(leaked)) +""" + + +def _assurance_modules_loaded_by(module: str) -> list[str]: + result = subprocess.run( + [sys.executable, "-c", _PROBE.format(module=module)], + capture_output=True, + text=True, + check=True, + ) + out = result.stdout.strip() + return [m for m in out.split(",") if m] + + +class TestRuntimeImportDirection: + def test_governance_does_not_load_assurance(self) -> None: + assert _assurance_modules_loaded_by("graqle.governance") == [] + + def test_kg_write_gate_does_not_load_assurance(self) -> None: + # The static graph flags kg_write_gate -> config.settings -> assurance; + # at runtime the config.settings edge is TYPE_CHECKING-only and the + # assurance edges are lazy, so nothing is loaded. + assert _assurance_modules_loaded_by("graqle.governance.kg_write_gate") == [] + + def test_trace_schema_does_not_load_assurance(self) -> None: + # This is why GateVerdictRef is duplicated rather than imported. + assert _assurance_modules_loaded_by("graqle.governance.trace_schema") == [] + + def test_config_settings_does_not_load_assurance_with_the_flag_off(self) -> None: + assert _assurance_modules_loaded_by("graqle.config.settings") == [] + + +class TestAssuranceMayImportGovernance: + def test_direction_is_one_way(self) -> None: + # The permitted direction: assurance -> governance. + import graqle.assurance # noqa: F401 + import graqle.governance # noqa: F401 diff --git a/tests/test_assurance/test_outcomes.py b/tests/test_assurance/test_outcomes.py new file mode 100644 index 00000000..538541a1 --- /dev/null +++ b/tests/test_assurance/test_outcomes.py @@ -0,0 +1,115 @@ +"""CR-012 / PR-012b — ``graqle.assurance.outcomes`` (CR-012 §4.1). + +Pins the five-member vocabulary, the terminal predicate and the severity order +CR-015 composes with. No numeric threshold appears here (CR-012 §12). +""" + +from __future__ import annotations + +import pytest + +from graqle.assurance.outcomes import OUTCOME_SEVERITY, GateOutcome, worst + + +class TestVocabulary: + def test_exactly_five_members(self) -> None: + assert [o.value for o in GateOutcome] == [ + "EXECUTE", + "REPLAN", + "HOLD", + "REJECT", + "ESCALATE", + ] + + def test_str_valued_so_it_serialises_without_an_encoder(self) -> None: + import json + + assert json.dumps({"o": GateOutcome.HOLD}) == '{"o": "HOLD"}' + + def test_does_not_extend_governance_decision(self) -> None: + # Senior Q1 unanimous: GateOutcome is a separate vocabulary. + from graqle.governance.trace_schema import Decision + + assert not issubclass(GateOutcome, Decision) + assert {o.value for o in GateOutcome}.isdisjoint({d.value for d in Decision}) + + +class TestTerminal: + @pytest.mark.parametrize("outcome", [GateOutcome.REJECT, GateOutcome.ESCALATE]) + def test_terminal_outcomes(self, outcome: GateOutcome) -> None: + assert outcome.terminal is True + + @pytest.mark.parametrize( + "outcome", [GateOutcome.EXECUTE, GateOutcome.HOLD, GateOutcome.REPLAN] + ) + def test_non_terminal_outcomes(self, outcome: GateOutcome) -> None: + assert outcome.terminal is False + + def test_replan_is_explicitly_not_terminal(self) -> None: + # REPLAN re-enters the gate; CR-015's circuit breaker stops the loop, + # not this flag. Regression guard for the spec's explicit statement. + assert GateOutcome.REPLAN.terminal is False + + +class TestSeverityOrder: + def test_every_member_is_ranked(self) -> None: + assert set(OUTCOME_SEVERITY) == set(GateOutcome) + + def test_execute_is_least_severe_and_reject_most(self) -> None: + assert OUTCOME_SEVERITY[GateOutcome.EXECUTE] == min(OUTCOME_SEVERITY.values()) + assert OUTCOME_SEVERITY[GateOutcome.REJECT] == max(OUTCOME_SEVERITY.values()) + + def test_escalate_ranks_below_reject(self) -> None: + # A human may still authorise an ESCALATE; REJECT is final. + assert ( + OUTCOME_SEVERITY[GateOutcome.ESCALATE] + < OUTCOME_SEVERITY[GateOutcome.REJECT] + ) + + def test_worst_wins(self) -> None: + assert ( + worst([GateOutcome.EXECUTE, GateOutcome.REJECT, GateOutcome.HOLD]) + is GateOutcome.REJECT + ) + assert worst([GateOutcome.EXECUTE]) is GateOutcome.EXECUTE + + def test_worst_refuses_an_empty_iterable(self) -> None: + # Returning a default here would be a fail-open path. + with pytest.raises(ValueError): + worst([]) + + +class TestWorstTerminal: + """Companion to ``worst`` for callers branching on "does this end the loop?".""" + + def test_returns_the_worst_terminal_outcome(self) -> None: + from graqle.assurance.outcomes import worst_terminal + + assert ( + worst_terminal([GateOutcome.ESCALATE, GateOutcome.REJECT]) + is GateOutcome.REJECT + ) + + def test_returns_none_when_nothing_is_terminal(self) -> None: + from graqle.assurance.outcomes import worst_terminal + + assert worst_terminal([GateOutcome.EXECUTE, GateOutcome.HOLD]) is None + + def test_ignores_non_terminal_outcomes(self) -> None: + from graqle.assurance.outcomes import worst_terminal + + assert ( + worst_terminal([GateOutcome.REJECT, GateOutcome.HOLD, GateOutcome.EXECUTE]) + is GateOutcome.REJECT + ) + + def test_worst_may_return_a_non_terminal_outcome(self) -> None: + # The trap worst_terminal exists to avoid: worst() of two HOLDs is a + # HOLD, which is NOT terminal. + assert worst([GateOutcome.HOLD, GateOutcome.HOLD]) is GateOutcome.HOLD + assert worst([GateOutcome.HOLD, GateOutcome.HOLD]).terminal is False + + def test_empty_is_none_not_an_error(self) -> None: + from graqle.assurance.outcomes import worst_terminal + + assert worst_terminal([]) is None diff --git a/tests/test_assurance/test_reason_codes.py b/tests/test_assurance/test_reason_codes.py new file mode 100644 index 00000000..8366eff1 --- /dev/null +++ b/tests/test_assurance/test_reason_codes.py @@ -0,0 +1,173 @@ +"""CR-012 / PR-012b — the closed reason-code registry (CR-012 §4.2, R1 + R2). + +AC-8: every seeded code matches the grammar; hard-gate codes are FAIL/CRITICAL; +REGISTRY is immutable; the grammar accepts exactly the specified language +(Hypothesis fuzz). + +INV-RC-2 (append-only): a code may be ADDED, never removed and never re-spelled — +CR-014/015/017/018 register through REGISTRY, and a re-spelled code silently +breaks their reason references. This release ships an empty seed, so the +invariant is asserted structurally rather than against a code snapshot; the +snapshot test returns with the seeding that begins in CR-013. + +Registry-dependent behaviour (validate/max_severity/resolvable) is exercised +against a local fixture registry built in-test, so these tests do not depend on +which codes the shipped seed happens to contain. +""" + +from __future__ import annotations + +import re +from types import MappingProxyType + +import pytest +from hypothesis import given, settings +from hypothesis import strategies as st + +from graqle.assurance import reason_codes +from graqle.assurance.reason_codes import ( + CODE_PATTERN, + REGISTRY, + ReasonCode, + Severity, + max_severity, + resolvable, + validate, +) + +# Fixture registry. Synthetic codes only: these exercise registry-dependent +# behaviour without asserting which codes the shipped seed contains. +_FIXTURE: tuple[ReasonCode, ...] = ( + ReasonCode("DAG-CV-AA", Severity.WARN, "CV", "Advisory; re-run to refresh.", "0.84.1"), + ReasonCode("DAG-TR-BB", Severity.INFO, "TR", "Informational only.", "0.84.1"), + ReasonCode("DAG-HG01-CC", Severity.CRITICAL, "HG-01", "", "0.84.1"), + ReasonCode("DAG-HG02-DD", Severity.FAIL, "HG-02", "Re-run with fresh evidence.", "0.84.1"), +) +_FIXTURE_REGISTRY = {rc.code: rc for rc in _FIXTURE} + + +@pytest.fixture +def fixture_registry(monkeypatch: pytest.MonkeyPatch) -> None: + """Point the registry-reading helpers at the synthetic fixture set.""" + monkeypatch.setattr( + reason_codes, "REGISTRY", MappingProxyType(_FIXTURE_REGISTRY) + ) + + +class TestAppendOnly: + def test_seed_is_empty_at_this_version(self) -> None: + # 0.84.1 ships the grammar and the invariants; seeding begins in CR-013. + # Appending to an empty seed is a plain append, so INV-RC-2 is intact. + assert dict(REGISTRY) == {} + + def test_registry_is_immutable(self) -> None: + with pytest.raises(TypeError): + REGISTRY["DAG-CV-NEW"] = None # type: ignore[index] + + +class TestGrammarAndSeverity: + def test_every_seeded_code_matches_the_grammar(self) -> None: + assert not [c for c in REGISTRY if not CODE_PATTERN.match(c)] + + def test_hard_gate_codes_are_never_advisory(self) -> None: + # INV-RC-3 + advisory = [ + c + for c, rc in REGISTRY.items() + if rc.owner_gate.startswith("HG-") + and rc.severity in (Severity.INFO, Severity.WARN) + ] + assert not advisory + + def test_hard_gate_fail_severity_is_constructible_per_oq4(self) -> None: + # OQ-4: an HG-02 code is seeded at FAIL (resolvable), not CRITICAL. + # Asserted on the invariant rather than on a seeded code, since this + # version ships an empty seed. + rc = ReasonCode( + "DAG-HG02-DD", Severity.FAIL, "HG-02", "Re-run with fresh evidence.", "0.84.1" + ) + assert rc.severity is Severity.FAIL + + def test_remediation_hints_state_no_numbers(self) -> None: + # Hints are PUBLIC strings; a threshold must never appear (CR-012 §12). + numeric = re.compile(r"\d") + offenders = { + c: rc.remediation_hint + for c, rc in REGISTRY.items() + if numeric.search(rc.remediation_hint) + } + assert not offenders, offenders + + def test_construction_rejects_a_bad_spelling(self) -> None: + with pytest.raises(ValueError, match="grammar"): + ReasonCode("BAD-CODE", Severity.FAIL, "CV", "hint", "0.85.0") + + def test_construction_rejects_an_advisory_hard_gate_code(self) -> None: + # The suffix must be >= 2 chars, otherwise the GRAMMAR check fires first + # and INV-RC-3 is never reached. "XX" is well-formed, so this test + # actually exercises the severity invariant. + with pytest.raises(ValueError, match="INV-RC-3"): + ReasonCode("DAG-HG01-XX", Severity.WARN, "HG-01", "hint", "0.85.0") + + def test_grammar_is_checked_before_severity(self) -> None: + # A malformed code fails on grammar even when it is also advisory. + with pytest.raises(ValueError, match="grammar"): + ReasonCode("DAG-HG01-X", Severity.WARN, "HG-01", "hint", "0.85.0") + + +class TestGrammarFuzz: + """AC-8 — the grammar accepts exactly the specified language.""" + + @given( + namespace=st.sampled_from( + ["HG01", "HG02", "HG03", "HG04", "HG05", "HG06", "HG07", "CV", "TR", "PV", "RP"] + ), + suffix=st.text( + alphabet=st.sampled_from(list("ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-")), + min_size=2, + max_size=48, + ), + ) + @settings(max_examples=200, deadline=None) + def test_well_formed_codes_are_accepted(self, namespace: str, suffix: str) -> None: + assert CODE_PATTERN.match(f"DAG-{namespace}-{suffix}") + + @given(st.text(max_size=60)) + @settings(max_examples=300, deadline=None) + def test_arbitrary_text_is_accepted_only_if_it_matches(self, raw: str) -> None: + # The regex is the single source of truth; this asserts no other + # acceptance path exists. + expected = bool( + re.match(r"^DAG-(HG0[1-7]|CV|TR|PV|RP)-[A-Z0-9_-]{2,48}$", raw) + ) + assert bool(CODE_PATTERN.match(raw)) is expected + + @given(st.sampled_from(["DAG-HG08-X", "DAG-XX-YY", "dag-cv-lower", "DAG-CV-A", ""])) + def test_known_bad_shapes_are_rejected(self, raw: str) -> None: + assert not CODE_PATTERN.match(raw) + + +class TestValidateAndSeverityHelpers: + def test_unknown_codes_fail_closed(self) -> None: + with pytest.raises(ValueError, match="unregistered"): + validate(["DAG-XX-NOPE"]) + + def test_order_preserved_and_duplicates_removed( + self, fixture_registry: None + ) -> None: + codes = ["DAG-CV-AA", "DAG-TR-BB", "DAG-CV-AA"] + assert validate(codes) == ["DAG-CV-AA", "DAG-TR-BB"] + + def test_max_severity(self, fixture_registry: None) -> None: + assert max_severity(["DAG-CV-AA", "DAG-HG01-CC"]) is Severity.CRITICAL + assert max_severity([]) is None + + def test_resolvable_follows_ruling_r2(self, fixture_registry: None) -> None: + # INFO/WARN -> resolvable; FAIL with a hint -> resolvable; CRITICAL -> never. + assert resolvable("DAG-CV-AA") is True + assert resolvable("DAG-HG02-DD") is True + assert resolvable("DAG-HG01-CC") is False + + def test_resolvable_rejects_an_unregistered_code(self) -> None: + with pytest.raises(ValueError, match="unregistered"): + resolvable("DAG-XX-NOPE") diff --git a/tests/test_assurance/test_verdict.py b/tests/test_assurance/test_verdict.py new file mode 100644 index 00000000..d4de7216 --- /dev/null +++ b/tests/test_assurance/test_verdict.py @@ -0,0 +1,185 @@ +"""CR-012 / PR-012b — ``GateVerdict`` and friends (CR-012 §4.3, AC-7). + +The verdict is fail-closed by construction: EXECUTE is unreachable whenever a +hard gate failed or the evaluation itself errored. Every model is +``extra="forbid"``. +""" + +from __future__ import annotations + +from datetime import datetime, timezone +from types import MappingProxyType + +import pytest +from pydantic import ValidationError + +from graqle.assurance import reason_codes +from graqle.assurance.outcomes import GateOutcome +from graqle.assurance.verdict import ( + VERDICT_SCHEMA_VERSION, + DeterminismRecord, + GateVerdict, + GateVerdictRef, + HardGateResultRef, +) + +_NOW = datetime(2026, 9, 16, 12, 0, 0, tzinfo=timezone.utc) +_HASH = "sha256:" + "a" * 64 + + +def _determinism(**overrides) -> DeterminismRecord: + defaults = { + "inputs_hash": _HASH, + "rule_id": "HG-01", + "rule_version": "1", + "config_version": "sha256:deadbeef", + } + defaults.update(overrides) + return DeterminismRecord(**defaults) + + +def _verdict(**overrides) -> GateVerdict: + defaults = { + "outcome": GateOutcome.HOLD, + "determinism_record": _determinism(), + "evaluated_at": _NOW, + } + defaults.update(overrides) + return GateVerdict(**defaults) + + +class TestDeterminismRecord: + def test_inputs_hash_shape_is_enforced(self) -> None: + with pytest.raises(ValidationError): + _determinism(inputs_hash="not-a-hash") + + def test_probabilistic_requires_variance(self) -> None: + with pytest.raises(ValidationError, match="variance"): + _determinism(probabilistic=True) + + def test_probabilistic_with_variance_is_accepted(self) -> None: + assert _determinism(probabilistic=True, variance=0.31).variance == 0.31 + + +class TestHardGateResultRef: + def test_error_implies_failure(self) -> None: + with pytest.raises(ValidationError, match="must not pass"): + HardGateResultRef( + rule_id="HG-01", + rule_version="1", + passed=True, + inputs_hash=_HASH, + evaluated_at=_NOW, + error="gate raised", + ) + + def test_error_on_a_failed_gate_is_fine(self) -> None: + ref = HardGateResultRef( + rule_id="HG-01", + rule_version="1", + passed=False, + inputs_hash=_HASH, + evaluated_at=_NOW, + error="gate raised", + ) + assert ref.passed is False + + +class TestGateVerdictFailClosed: + def test_execute_is_impossible_with_a_failed_hard_gate(self) -> None: + failed = HardGateResultRef( + rule_id="HG-01", + rule_version="1", + passed=False, + inputs_hash=_HASH, + evaluated_at=_NOW, + ) + with pytest.raises(ValidationError, match="failed hard gate"): + _verdict(outcome=GateOutcome.EXECUTE, hard_gate_results=[failed]) + + def test_execute_is_impossible_with_evaluation_errors(self) -> None: + with pytest.raises(ValidationError, match="evaluation_errors"): + _verdict(outcome=GateOutcome.EXECUTE, evaluation_errors=["boom"]) + + def test_execute_is_allowed_when_every_gate_passed(self) -> None: + passed = HardGateResultRef( + rule_id="HG-01", + rule_version="1", + passed=True, + inputs_hash=_HASH, + evaluated_at=_NOW, + ) + assert _verdict( + outcome=GateOutcome.EXECUTE, hard_gate_results=[passed] + ).outcome is GateOutcome.EXECUTE + + def test_a_failed_gate_is_fine_on_a_non_execute_outcome(self) -> None: + failed = HardGateResultRef( + rule_id="HG-01", + rule_version="1", + passed=False, + inputs_hash=_HASH, + evaluated_at=_NOW, + ) + assert _verdict( + outcome=GateOutcome.REJECT, hard_gate_results=[failed] + ).outcome is GateOutcome.REJECT + + +class TestGateVerdictSchema: + def test_default_schema_version(self) -> None: + assert _verdict().schema_version == VERDICT_SCHEMA_VERSION == "1" + + def test_unknown_field_is_rejected(self) -> None: + with pytest.raises(ValidationError): + _verdict(bogus=1) + + def test_unregistered_reason_code_is_rejected(self) -> None: + with pytest.raises(ValidationError, match="unregistered"): + _verdict(reason_codes=["DAG-XX-NOPE"]) + + def test_registered_reason_codes_are_deduplicated_in_order( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + # This version ships an empty seed, so registration is exercised against + # a synthetic fixture rather than against seeded codes. + fixture = { + "DAG-CV-AA": reason_codes.ReasonCode( + "DAG-CV-AA", reason_codes.Severity.WARN, "CV", "Advisory.", "0.84.1" + ), + "DAG-TR-BB": reason_codes.ReasonCode( + "DAG-TR-BB", reason_codes.Severity.INFO, "TR", "Informational.", "0.84.1" + ), + } + monkeypatch.setattr(reason_codes, "REGISTRY", MappingProxyType(fixture)) + v = _verdict( + reason_codes=["DAG-CV-AA", "DAG-TR-BB", "DAG-CV-AA"] + ) + assert v.reason_codes == ["DAG-CV-AA", "DAG-TR-BB"] + + def test_confidence_vector_range_is_enforced(self) -> None: + with pytest.raises(ValidationError, match="out of range"): + _verdict(confidence_vector={"grounding": 1.7}) + + def test_confidence_vector_stays_optional_until_cr014(self) -> None: + assert _verdict().confidence_vector is None + assert _verdict().trajectory_reliability is None + + +class TestGateVerdictRef: + def test_outcome_is_a_plain_string(self) -> None: + # The trace side must not import the GateOutcome enum. + ref = GateVerdictRef( + verdict_schema_version="1", + outcome="REJECT", + reason_codes=["DAG-HG01-CC"], + inputs_hash=_HASH, + config_version="sha256:c", + ) + assert isinstance(ref.outcome, str) + assert ref.evaluation_error_count == 0 + + def test_carries_no_confidence_vector(self) -> None: + # TS-1/TS-2: the ref carries hashes and codes, never weights. + assert "confidence_vector" not in GateVerdictRef.model_fields + assert "trajectory_reliability" not in GateVerdictRef.model_fields diff --git a/tests/test_cli/test_doctor_neo4j_activation_line.py b/tests/test_cli/test_doctor_neo4j_activation_line.py new file mode 100644 index 00000000..18136257 --- /dev/null +++ b/tests/test_cli/test_doctor_neo4j_activation_line.py @@ -0,0 +1,216 @@ +"""CR-012 PR-012d (ruling N7) — the ``graq doctor`` activation line. + +On a neo4j-first project, ``graq doctor`` must report whether semantic +activation can actually run: vector-index state plus chunk-embedding coverage. +Before this, a substrate with no index (or zero embeddings) produced a doctor +report that looked entirely healthy while every retrieval-dependent answer came +from a graph that could not activate. + +These tests assert OBSERVABLE CLI BEHAVIOUR — the rendered report and the +check status — never source strings. The line must appear ONLY for neo4j-first +projects (review checklist item 4). +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import pytest + +from graqle.cli.commands import doctor as doctor_mod + + +def _neo4j_yaml(tmp_path: Path) -> Path: + cfg = tmp_path / "graqle.yaml" + cfg.write_text( + "graph:\n" + " connector: neo4j\n" + " uri: bolt://localhost:7687\n" + " username: neo4j\n" + " password: secret\n" + " database: research\n", + encoding="utf-8", + ) + return cfg + + +def _json_yaml(tmp_path: Path) -> Path: + cfg = tmp_path / "graqle.yaml" + cfg.write_text("graph:\n connector: networkx\n", encoding="utf-8") + return cfg + + +class _FakeConnector: + """Stands in for Neo4jConnector; records that close() is honoured.""" + + instances: list[_FakeConnector] = [] + + def __init__(self, **kwargs: Any) -> None: + self.kwargs = kwargs + self.closed = False + self.probe: dict[str, Any] = {} + _FakeConnector.instances.append(self) + + def probe_vector_index(self) -> dict[str, Any]: + return dict(self.probe) + + def close(self) -> None: + self.closed = True + + +def _install_fake(monkeypatch: pytest.MonkeyPatch, probe: dict[str, Any]) -> None: + """Patch Neo4jConnector, tolerating a CI env with no `neo4j` driver. + + `graqle.connectors.neo4j` imports the optional driver at module load, and + CI installs only the [dev] extra. Insert a stub module when the real one + cannot import, so these tests exercise the doctor logic rather than the + availability of an optional dependency. + """ + _FakeConnector.instances.clear() + + def _factory(**kwargs: Any) -> _FakeConnector: + conn = _FakeConnector(**kwargs) + conn.probe = probe + return conn + + try: + import graqle.connectors.neo4j as neo4j_mod + except ImportError: # pragma: no cover - only on a driver-less install + import sys + import types + + neo4j_mod = types.ModuleType("graqle.connectors.neo4j") + monkeypatch.setitem(sys.modules, "graqle.connectors.neo4j", neo4j_mod) + + monkeypatch.setattr(neo4j_mod, "Neo4jConnector", _factory, raising=False) + + +def _activation_rows(results: list[tuple[str, str, str]]) -> list[tuple[str, str, str]]: + return [r for r in results if r[1] == "Neo4j: activation"] + + +_UNUSABLE = { + "index_name": "cogni_chunk_embedding_index", + "database": "research", + "index_state": "NOT_FOUND", + "chunks_total": 3722, + "chunks_embedded": 0, + "usable": False, +} +_USABLE = {**_UNUSABLE, "index_state": "ONLINE", "chunks_embedded": 3722, "usable": True} + + +class TestNeo4jFirstProjects: + def test_unusable_substrate_is_reported_as_failure( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.chdir(tmp_path) + _neo4j_yaml(tmp_path) + _install_fake(monkeypatch, _UNUSABLE) + + rows = _activation_rows(doctor_mod._check_neo4j_backend()) + assert rows, "neo4j-first project must get an activation line" + status, _, detail = rows[0] + assert status == doctor_mod.FAIL + assert "0/3722" in detail + assert "NOT_FOUND" in detail + + def test_healthy_substrate_passes( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.chdir(tmp_path) + _neo4j_yaml(tmp_path) + _install_fake(monkeypatch, _USABLE) + + rows = _activation_rows(doctor_mod._check_neo4j_backend()) + assert rows + status, _, detail = rows[0] + assert status == doctor_mod.PASS + assert "3722/3722" in detail + + def test_zero_embeddings_with_online_index_still_fails( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + # The D1 shape: index exists and is ONLINE, but nothing is embedded, + # so it can never match. Must NOT read as healthy. + monkeypatch.chdir(tmp_path) + _neo4j_yaml(tmp_path) + _install_fake(monkeypatch, {**_UNUSABLE, "index_state": "ONLINE"}) + + rows = _activation_rows(doctor_mod._check_neo4j_backend()) + assert rows and rows[0][0] == doctor_mod.FAIL + + def test_connector_is_closed( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.chdir(tmp_path) + _neo4j_yaml(tmp_path) + _install_fake(monkeypatch, _USABLE) + + doctor_mod._check_neo4j_backend() + assert _FakeConnector.instances, "connector should have been constructed" + assert all(c.closed for c in _FakeConnector.instances) + + def test_probe_failure_degrades_to_warning_not_crash( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.chdir(tmp_path) + _neo4j_yaml(tmp_path) + + def _boom(**kwargs: Any) -> Any: + raise RuntimeError("connection refused") + + import graqle.connectors.neo4j as neo4j_mod + + monkeypatch.setattr(neo4j_mod, "Neo4jConnector", _boom, raising=True) + + rows = _activation_rows(doctor_mod._check_neo4j_backend()) + # doctor must never crash; it reports the probe failure instead. + assert not rows or rows[0][0] == doctor_mod.WARN + + +class TestNonNeo4jProjects: + def test_no_activation_line_on_json_backend( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + # Review checklist (4): the line exists ONLY for neo4j-first projects. + monkeypatch.chdir(tmp_path) + _json_yaml(tmp_path) + + assert _activation_rows(doctor_mod._check_neo4j_backend()) == [] + + def test_no_activation_line_without_config( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.chdir(tmp_path) + assert _activation_rows(doctor_mod._check_neo4j_backend()) == [] + + +class TestDriverAbsent: + """CI installs only [dev], so the optional neo4j driver is missing there. + + The activation line previously lived inside a try block headed by + `from neo4j import GraphDatabase`; on a driver-less install that raised and + skipped the whole block, so the check silently vanished — the exact class + of invisible gap ruling N7 exists to close. A missing driver is itself a + reportable answer. + """ + + def test_missing_driver_is_reported_not_skipped( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + import builtins + + real_import = builtins.__import__ + + def _no_connector(name: str, *args: Any, **kwargs: Any) -> Any: + if name == "graqle.connectors.neo4j": + raise ImportError("No module named neo4j") + return real_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", _no_connector) + + rows = _activation_rows(doctor_mod._check_neo4j_activation({"graph": {}})) + assert rows, "a missing driver must still produce an activation row" + assert rows[0][0] == doctor_mod.FAIL diff --git a/tests/test_core/test_governance_threshold_resolver.py b/tests/test_core/test_governance_threshold_resolver.py new file mode 100644 index 00000000..ce73e978 --- /dev/null +++ b/tests/test_core/test_governance_threshold_resolver.py @@ -0,0 +1,205 @@ +"""CR-012 PR-012c — AC-9, AC-10, AC-11: the single governance threshold resolver. + +Before this, `GraqleConfig.governance` (parsed from graqle.yaml) and +`GovernanceConfig` (what the middleware runs on) declared the same eight keys +independently, and nothing copied one into the other. An operator who set +`governance.review_threshold` in graqle.yaml got the hard-coded default, with no +warning. The yaml keys were inert. + +AC-9 — flag-off behaviour is byte-identical. The 64-case golden was generated + ON THE v0.83.0 TAG by `scripts/gen_gate_result_golden.py`, not by this + branch; a golden produced by the code under test would prove nothing. + sha256: f724ff4e125eac46d6993b6b2afbe10dfd3c18a29a3156311edb4114b2735af4 +AC-10 — the two config shapes agree on their shared keys (drift lock). +AC-11 — a yaml override is honoured, and a WARNING is logged once. + +TS review focus: a threshold is TS-3 tuning. The resolver logs KEY NAMES only. +`test_warning_never_contains_a_threshold_value` is the guard. +""" + +from __future__ import annotations + +import hashlib +import json +import logging +from pathlib import Path + +import pytest + +from graqle.core.governance import GateResult, GovernanceConfig, GovernanceMiddleware +from graqle.core.governance_thresholds import ( + SHARED_KEYS, + _reset_warned_for_tests, + resolve_governance_config, +) + +_GOLDEN = Path(__file__).resolve().parents[1] / "fixtures" / "gate_result_golden_v0830.json" +_GOLDEN_SHA256 = "f724ff4e125eac46d6993b6b2afbe10dfd3c18a29a3156311edb4114b2735af4" + + +@pytest.fixture(autouse=True) +def _isolate_state() -> None: + """Reset both process-global memos this module touches. + + `_warned` is the resolver's warn-once set; `_cumulative` is + GovernanceMiddleware's class-level anti-gaming ledger. Leaving either + populated leaks into sibling tests. + """ + _reset_warned_for_tests() + GovernanceMiddleware._cumulative.clear() + + +class TestAC9GoldenUnchanged: + """Flag-off behaviour must not move.""" + + def test_golden_fixture_is_the_v0830_artifact(self) -> None: + # Pin the fixture itself: if someone regenerates it on the branch under + # test, the golden stops being evidence and this test says so. + digest = hashlib.sha256(_GOLDEN.read_bytes()).hexdigest() + assert digest == _GOLDEN_SHA256, ( + "the golden fixture no longer matches the sha256 recorded in the " + "PR description; it must be generated on the v0.83.0 tag" + ) + + def test_current_output_matches_the_v0830_golden(self) -> None: + cases = json.loads(_GOLDEN.read_text(encoding="utf-8")) + assert len(cases) == 64 + + mismatches: list[str] = [] + for case in cases: + payload = case["in"] + # The replay MUST mirror the generator's isolation exactly. + # `_cumulative` is a CLASS attribute persisted to + # .graqle/gov_cumulative.json and loaded once per process + # (`_state_loaded`), so a fresh instance is NOT enough: state leaks + # across cases and across runs, and each row would depend on every + # prior row. Clear the class dict and short-circuit the disk read. + GovernanceMiddleware._cumulative.clear() + GovernanceMiddleware._state_loaded = True + middleware = GovernanceMiddleware(config=GovernanceConfig()) + result = middleware.check( + diff=payload["diff"], + file_path="src/mod.py", + risk_level=payload["risk"], + impact_radius=payload["radius"], + approved_by=payload["approved_by"], + action="edit", + actor=payload["actor"], + ) + if result.to_dict() != case["out"]: + mismatches.append( + f"{payload} -> {result.to_dict()} != {case['out']}" + ) + assert not mismatches, "flag-off output drifted from v0.83.0:\n" + "\n".join( + mismatches[:5] + ) + + def test_defaults_are_untouched_without_yaml(self, tmp_path: Path, monkeypatch) -> None: + # No graqle.yaml at all -> exactly GovernanceConfig(). + monkeypatch.chdir(tmp_path) + assert resolve_governance_config() == GovernanceConfig() + + def test_gate_result_shape_is_unchanged(self) -> None: + # The golden compares to_dict(); pin the key set so a field addition is + # a deliberate act, not a silent golden rewrite. + result = GateResult(tier="T1", blocked=False, requires_approval=False, + gate_score=0.0, reason="") + assert set(result.to_dict()) == { + "tier", "blocked", "requires_approval", "gate_score", "reason", + "warnings", "bypass_allowed", "risk_level", "impact_radius", + } + + +class TestAC10DriftLock: + """The two config shapes must keep agreeing on their shared keys.""" + + def test_shared_keys_exist_on_both_shapes(self) -> None: + from graqle.config.settings import GovernancePolicyConfig + + policy_fields = set(GovernancePolicyConfig.model_fields) + governance_fields = set(GovernanceConfig().__dict__) + for key in SHARED_KEYS: + assert key in policy_fields, f"{key} missing from GovernancePolicyConfig" + assert key in governance_fields, f"{key} missing from GovernanceConfig" + + def test_shared_key_defaults_agree(self) -> None: + from graqle.config.settings import GovernancePolicyConfig + + policy = GovernancePolicyConfig() + governance = GovernanceConfig() + drift = { + key: (getattr(policy, key), getattr(governance, key)) + for key in SHARED_KEYS + if getattr(policy, key) != getattr(governance, key) + } + assert not drift, f"shared-key defaults have drifted: {drift}" + + def test_shared_keys_is_not_silently_incomplete(self) -> None: + # If BOTH shapes grow the same new key, SHARED_KEYS must learn about it + # or the resolver will ignore it — the exact defect this PR closes. + from graqle.config.settings import GovernancePolicyConfig + + common = set(GovernancePolicyConfig.model_fields) & set(GovernanceConfig().__dict__) + missing = common - set(SHARED_KEYS) + assert not missing, ( + f"these keys exist on both config shapes but are absent from " + f"SHARED_KEYS, so the resolver ignores them: {sorted(missing)}" + ) + + +class TestAC11YamlIsHonoured: + """A yaml override must reach the middleware, and say so once.""" + + def _policy(self, **overrides): + from graqle.config.settings import GovernancePolicyConfig + + return GovernancePolicyConfig(**overrides) + + def test_override_is_applied(self) -> None: + resolved = resolve_governance_config(self._policy(review_threshold=0.31)) + assert resolved.review_threshold == 0.31 + + def test_untouched_keys_keep_their_defaults(self) -> None: + resolved = resolve_governance_config(self._policy(review_threshold=0.31)) + defaults = GovernanceConfig() + assert resolved.block_threshold == defaults.block_threshold + assert resolved.ts_hard_block == defaults.ts_hard_block + + def test_warning_is_logged_for_an_override(self, caplog) -> None: + with caplog.at_level(logging.WARNING): + resolve_governance_config(self._policy(review_threshold=0.31)) + warnings = [r for r in caplog.records if "review_threshold" in r.getMessage()] + assert warnings, "an override that was previously ignored must be announced" + + def test_warning_is_logged_only_once(self, caplog) -> None: + with caplog.at_level(logging.WARNING): + resolve_governance_config(self._policy(review_threshold=0.31)) + resolve_governance_config(self._policy(review_threshold=0.31)) + warnings = [r for r in caplog.records if "review_threshold" in r.getMessage()] + assert len(warnings) == 1, "the gate runs per tool call; do not repeat" + + def test_warning_never_contains_a_threshold_value(self, caplog) -> None: + # TS-3: names only. A value in a log line is a leak. + with caplog.at_level(logging.WARNING): + resolve_governance_config(self._policy(review_threshold=0.31)) + text = " ".join(r.getMessage() for r in caplog.records) + assert "0.31" not in text + assert "0.70" not in text + + def test_no_warning_when_yaml_matches_the_default(self, caplog) -> None: + with caplog.at_level(logging.WARNING): + resolve_governance_config(self._policy()) + assert not [r for r in caplog.records if "overrides" in r.getMessage()] + + def test_explicit_config_still_wins(self) -> None: + # An explicitly-passed config must not be second-guessed by yaml. + explicit = GovernanceConfig(review_threshold=0.37) + assert GovernanceMiddleware(config=explicit).config is explicit + + def test_resolver_never_raises_on_bad_config(self, monkeypatch) -> None: + class _Exploding: + def __getattr__(self, name: str): + raise RuntimeError("config backend down") + + # A governance gate must construct even when config is unreadable. + assert resolve_governance_config(_Exploding()) is not None diff --git a/tests/test_core/test_neo4j_loader_pops_type.py b/tests/test_core/test_neo4j_loader_pops_type.py new file mode 100644 index 00000000..e7398b0e --- /dev/null +++ b/tests/test_core/test_neo4j_loader_pops_type.py @@ -0,0 +1,165 @@ +"""CR-012 PR-012c — the Neo4j loader must pop `type` (rulings process note 1). + +The node query aliases `n.entity_type AS type`, so `record["type"]` carries the +entity type. But a node that ALSO stores a literal `type` property leaves it in +`properties`, because the loader popped only id/label/entity_type/description. + +`Graqle.to_networkx` then does: + + G.add_node(nid, label=..., type=node.entity_type, **node.properties) + +and Python raises ``TypeError: add_node() got multiple values for keyword +argument 'type'``. 0.79.0 tolerated a stored `type`; the 0.83.0 hydrator +collides on it. The Research Team hit this on real data and removed the property +from their own nodes — but the SDK should not depend on every writer being +careful. + +These tests reproduce the collision at the hydrator, then pin the loader fix. +No live Neo4j is required: the loader is exercised through a fake session that +returns the record shape the real driver produces. +""" + +from __future__ import annotations + +from typing import Any + +import pytest + + +class _FakeRecord(dict): + """Mimics a neo4j Record: dict-like with .get().""" + + +class _FakeResult(list): + pass + + +class _FakeSession: + def __init__(self, node_rows: list[dict[str, Any]]) -> None: + self._node_rows = node_rows + + def run(self, query: str, **kwargs: Any) -> _FakeResult: + if "MATCH (n:CogniNode)" in query and "RETURN" in query: + return _FakeResult(_FakeRecord(r) for r in self._node_rows) + return _FakeResult() + + def __enter__(self) -> _FakeSession: + return self + + def __exit__(self, *exc: object) -> None: + return None + + +class _FakeDriver: + def __init__(self, node_rows: list[dict[str, Any]]) -> None: + self._node_rows = node_rows + + def session(self, **kwargs: Any) -> _FakeSession: + return _FakeSession(self._node_rows) + + +def _load_with(node_rows: list[dict[str, Any]]) -> dict[str, Any]: + from graqle.connectors.neo4j import Neo4jConnector + + connector = Neo4jConnector(uri="bolt://unused", password="") + connector._driver = _FakeDriver(node_rows) # bypass connect + connector._get_driver = lambda: connector._driver # type: ignore[method-assign] + nodes, _edges = connector.load() + return nodes + + +_ROW_WITH_STORED_TYPE = { + "id": "mod.py", + "label": "mod", + "type": "PythonModule", # aliased from n.entity_type + "description": "a module", + "properties": { + "id": "mod.py", + "label": "mod", + "entity_type": "PythonModule", + "description": "a module", + "type": "LEGACY_STRING", # the offender: a STORED `type` property + "source_project": "Graqle", + }, +} + + +class TestLoaderPopsType: + def test_stored_type_is_not_left_in_properties(self) -> None: + nodes = _load_with([_ROW_WITH_STORED_TYPE]) + assert "type" not in nodes["mod.py"]["properties"], ( + "a stored `type` property survives into properties and collides " + "with the hydrator's own type= keyword" + ) + + def test_entity_type_still_comes_from_the_alias(self) -> None: + # Popping `type` from properties must NOT disturb the real entity type, + # which the query aliases from n.entity_type. + nodes = _load_with([_ROW_WITH_STORED_TYPE]) + assert nodes["mod.py"]["type"] == "PythonModule" + + def test_other_properties_are_preserved(self) -> None: + nodes = _load_with([_ROW_WITH_STORED_TYPE]) + assert nodes["mod.py"]["properties"]["source_project"] == "Graqle" + + def test_node_without_a_stored_type_is_unaffected(self) -> None: + row = { + **_ROW_WITH_STORED_TYPE, + "properties": {"source_project": "Graqle"}, + } + nodes = _load_with([row]) + assert nodes["mod.py"]["type"] == "PythonModule" + assert nodes["mod.py"]["properties"] == {"source_project": "Graqle"} + + def test_pop_list_matches_the_writer_exclusions(self) -> None: + # Read and write paths must agree, or a round-trip reintroduces the + # collision. The writer excludes id/label/entity_type/description + # (+chunks); the loader must pop the same set plus the `type` alias. + import inspect + + from graqle.connectors.neo4j import Neo4jConnector + + source = inspect.getsource(Neo4jConnector.load) + assert '"type"' in source, "loader must pop the aliased `type` key" + + +class TestHydratorNoLongerCollides: + """The actual failure the pop prevents, at the hydrator.""" + + def test_to_networkx_survives_a_stored_type(self) -> None: + pytest.importorskip("networkx") + from graqle.core.graph import Graqle + from graqle.core.node import CogniNode + + graph = Graqle() + node = CogniNode( + id="mod.py", + label="mod", + entity_type="PythonModule", + description="a module", + ) + # Simulate a graph loaded from Neo4j BEFORE the fix: a `type` key left + # in properties. add_node would then receive type= twice. + node.properties = {"type": "LEGACY_STRING"} + graph.nodes["mod.py"] = node + + with pytest.raises(TypeError, match="multiple values for keyword"): + graph.to_networkx() + + def test_to_networkx_is_fine_once_the_loader_has_popped_type(self) -> None: + pytest.importorskip("networkx") + from graqle.core.graph import Graqle + from graqle.core.node import CogniNode + + graph = Graqle() + node = CogniNode( + id="mod.py", + label="mod", + entity_type="PythonModule", + description="a module", + ) + node.properties = {"source_project": "Graqle"} # loader popped `type` + graph.nodes["mod.py"] = node + + networkx_graph = graph.to_networkx() + assert networkx_graph.nodes["mod.py"]["type"] == "PythonModule" diff --git a/tests/test_governance/test_governed_trace_regression_v2.py b/tests/test_governance/test_governed_trace_regression_v2.py index fa908e2b..094e6668 100644 --- a/tests/test_governance/test_governed_trace_regression_v2.py +++ b/tests/test_governance/test_governed_trace_regression_v2.py @@ -38,6 +38,7 @@ from pydantic import ValidationError from graqle.governance.trace_schema import ( + read_trace, ClearanceLevel, GovernedTrace, Outcome, @@ -78,10 +79,18 @@ def test_parse_legacy_dict_without_new_fields(self): "outcome": "SUCCESS", "confidence": 0.9, } - trace = GovernedTrace.model_validate(legacy) - # cr-017 defaults populate the new fields. - assert trace.schema_version == "2" + # CR-012 PR-012b: read_trace() is the ONLY correct deserialization + # path for a record read from disk. It PRESERVES the version the record + # was written under; bare model_validate() would stamp it with the + # current writer default ("3"), asserting a schema generation the + # record predates. That value reaches governance_metadata inside the + # frozen LEAF_HASH_FIELDS allowlist, so a relabelled record hashes to a + # different Merkle leaf than it did when committed. + trace = read_trace(legacy) + # A pre-cr-017 record carries no schema_version on disk -> classified v1. + assert trace.schema_version == "1" assert trace.policy_version is None + assert trace.assurance is None # Pre-cr-017 fields preserve original values. assert trace.tool_name == "graq_inspect" assert trace.confidence == 0.9 @@ -102,15 +111,16 @@ def test_parse_legacy_dict_with_full_pre_cr017_field_set(self): "override_reason": None, "error": None, } - trace = GovernedTrace.model_validate(legacy) + trace = read_trace(legacy) # All pre-cr-017 fields preserve original values. assert trace.tool_name == "graq_review" assert trace.context_nodes == ["graqle/core/graph.py"] assert trace.clearance_level == ClearanceLevel.CONFIDENTIAL assert trace.cost_usd == 0.05 - # New fields use defaults. - assert trace.schema_version == "2" + # Version preserved as written (v1: field absent on disk), NOT relabelled. + assert trace.schema_version == "1" assert trace.policy_version is None + assert trace.assurance is None # ------------------------------------------------------------------------- diff --git a/tests/test_governance/test_governed_trace_schema_v2.py b/tests/test_governance/test_governed_trace_schema_v2.py index 6dfd0668..c17b7386 100644 --- a/tests/test_governance/test_governed_trace_schema_v2.py +++ b/tests/test_governance/test_governed_trace_schema_v2.py @@ -70,8 +70,10 @@ def _minimal_trace(**overrides) -> GovernedTrace: class TestModuleConstants: """Pin the public constants that downstream tooling depends on.""" - def test_current_schema_version_is_2(self): - assert CURRENT_SCHEMA_VERSION == "2" + def test_current_schema_version_is_3(self): + # CR-012 PR-012b bumped the writer version to "3" (additive `assurance` + # field). Readers still accept "1" and "2" via read_trace(). + assert CURRENT_SCHEMA_VERSION == "3" def test_legacy_policy_version_sentinel_value(self): # The exact sentinel string is part of the public OPSF Use B contract; @@ -93,7 +95,7 @@ def test_sentinel_is_string_not_none(self): class TestSchemaVersionField: def test_default_is_current_schema_version(self): t = _minimal_trace() - assert t.schema_version == "2" + assert t.schema_version == "3" assert t.schema_version == CURRENT_SCHEMA_VERSION def test_explicit_value_accepted(self): @@ -104,19 +106,19 @@ def test_explicit_value_accepted(self): def test_appears_in_to_internal_dict(self): t = _minimal_trace() d = t.to_internal_dict() - assert d["schema_version"] == "2" + assert d["schema_version"] == "3" def test_appears_in_to_public_dict(self): # Public serialization must include schema_version (not gated by TS-2). t = _minimal_trace() d = t.to_public_dict() - assert d["schema_version"] == "2" + assert d["schema_version"] == "3" def test_appears_in_json_roundtrip(self): t = _minimal_trace() encoded = json.dumps(t.to_internal_dict(), default=str) parsed = json.loads(encoded) - assert parsed["schema_version"] == "2" + assert parsed["schema_version"] == "3" # ------------------------------------------------------------------------- @@ -260,6 +262,8 @@ def test_v2_record_can_have_null_policy_version(self): # A v2 record from an issuer that has not yet generated a baseline. t = _minimal_trace(schema_version="2", policy_version=None) d = t.to_internal_dict() + # An EXPLICIT version survives serialisation unchanged (the v3 default + # applies only when the caller supplies none). assert d["schema_version"] == "2" assert d["policy_version"] is None diff --git a/tests/test_governance/test_rdf_mapper_v2.py b/tests/test_governance/test_rdf_mapper_v2.py index 54db75e3..72297f2a 100644 --- a/tests/test_governance/test_rdf_mapper_v2.py +++ b/tests/test_governance/test_rdf_mapper_v2.py @@ -75,9 +75,15 @@ def _triples_with_predicate(g, predicate): class TestSchemaVersionTriple: """The schemaVersion triple must ALWAYS be emitted (the field has a - non-None default of "2", so it always has a value).""" + non-None default, so it always has a value). - def test_default_schema_version_emitted_as_2(self): + CR-012 PR-012b bumped that default from "2" to "3" (additive `assurance` + field). A trace constructed without an explicit version therefore emits + "3"; a record READ from disk keeps the version it was written under, which + is `read_trace()`'s job and is covered in test_trace_schema_v3_compat.py. + """ + + def test_default_schema_version_emitted_as_3(self): trace = _minimal_trace_with_inspect_step() g = _trace_to_rdf(trace) triples = _triples_with_predicate(g, _GQ.schemaVersion) @@ -85,10 +91,12 @@ def test_default_schema_version_emitted_as_2(self): f"Expected exactly one schemaVersion triple; got {len(triples)}" ) _, _, obj = triples[0] - assert str(obj) == "2" + assert str(obj) == "3" def test_explicit_schema_version_3_emitted(self): - # Forward-compat: future schema bumps must emit the actual version. + # An EXPLICIT version is emitted verbatim rather than being replaced by + # the default. ("3" is the current default as of PR-012b, so this now + # overlaps the case above; kept because it pins the explicit path.) trace = _minimal_trace_with_inspect_step(schema_version="3") g = _trace_to_rdf(trace) triples = _triples_with_predicate(g, _GQ.schemaVersion) diff --git a/tests/test_governance/test_trace_schema_v3_compat.py b/tests/test_governance/test_trace_schema_v3_compat.py new file mode 100644 index 00000000..d5a2e303 --- /dev/null +++ b/tests/test_governance/test_trace_schema_v3_compat.py @@ -0,0 +1,162 @@ +"""CR-012 / PR-012b — trace schema v3 compatibility (AC-12 … AC-15). + +v3 adds ONE optional field (`assurance`). The reader accepts v1/v2/v3 and +PRESERVES the version a record was written under; the writer omits `assurance` +while it is None so a v3 record stays readable by a v0.83.0 reader. +""" + +from __future__ import annotations + +import json + +import pytest + +from graqle.governance.tamper_evidence.leaf_input_schema import ( + LEAF_HASH_FIELDS, + LEAF_INPUT_VERSION, +) +from graqle.governance.trace_schema import ( + CURRENT_SCHEMA_VERSION, + SUPPORTED_SCHEMA_VERSIONS, + Decision, + GateVerdictRef, + GovernedTrace, + Outcome, + read_trace, +) + +_V1 = {"tool_name": "graq_inspect", "query": "old", "outcome": "SUCCESS", "confidence": 0.9} +_V2 = {**_V1, "schema_version": "2"} + + +def _ref() -> GateVerdictRef: + return GateVerdictRef( + verdict_schema_version="1", + outcome="REJECT", + reason_codes=["DAG-HG01-CC"], + decisive_rule="HG-01", + cap_applied=True, + inputs_hash="sha256:" + "a" * 64, + config_version="sha256:b", + ) + + +class TestConstants: + def test_writer_version_is_3(self) -> None: + assert CURRENT_SCHEMA_VERSION == "3" + + def test_reader_supports_1_2_3(self) -> None: + assert SUPPORTED_SCHEMA_VERSIONS == frozenset({"1", "2", "3"}) + + +class TestAC12LegacyRecords: + """v1 and v2 records read without error; assurance is None; version kept.""" + + def test_v1_record_keeps_its_version(self) -> None: + trace = read_trace(_V1) + assert trace.schema_version == "1" + assert trace.assurance is None + + def test_v2_record_keeps_its_version(self) -> None: + trace = read_trace(_V2) + assert trace.schema_version == "2" + assert trace.assurance is None + + def test_reader_never_mutates_the_input(self) -> None: + raw = dict(_V2) + snapshot = dict(raw) + read_trace(raw) + assert raw == snapshot + + def test_legacy_record_is_not_relabelled_as_v3(self) -> None: + # A relabelled record would assert a schema generation it predates, and + # schema_version reaches governance_metadata inside LEAF_HASH_FIELDS — + # so the record would hash to a different Merkle leaf than at commit. + assert read_trace(_V1).schema_version != CURRENT_SCHEMA_VERSION + + +class TestAC13V3RoundTrip: + def test_round_trip_preserves_the_verdict(self) -> None: + trace = GovernedTrace( + tool_name="graq_edit", + query="q", + outcome=Outcome.BLOCKED, + confidence=0.62, + assurance=_ref(), + ) + restored = read_trace(trace.to_internal_dict()) + assert restored.assurance is not None + assert restored.assurance.outcome == "REJECT" + assert restored.assurance.reason_codes == ["DAG-HG01-CC"] + assert restored.schema_version == "3" + + def test_public_dict_excludes_internal_fields(self) -> None: + trace = GovernedTrace( + tool_name="graq_edit", + query="q", + outcome=Outcome.BLOCKED, + confidence=0.62, + assurance=_ref(), + ) + public = trace.to_public_dict() + assert "assurance" not in public + assert "governance_decisions" not in public + + def test_assurance_is_omitted_entirely_while_none(self) -> None: + # Not `"assurance": null` — the key is absent, so a v0.83.0 reader with + # extra="forbid" still accepts the record. + trace = GovernedTrace( + tool_name="t", query="q", outcome=Outcome.SUCCESS, confidence=0.1 + ) + internal = trace.to_internal_dict() + assert "assurance" not in internal + json.dumps(internal, default=str) + + +class TestAC14UnknownVersions: + def test_strict_rejects_a_future_version(self) -> None: + with pytest.raises(ValueError, match="unsupported"): + read_trace({**_V1, "schema_version": "4"}, strict=True) + + def test_non_strict_warns_and_parses(self, caplog: pytest.LogCaptureFixture) -> None: + with caplog.at_level("WARNING"): + trace = read_trace( + {**_V1, "schema_version": "4", "brand_new_key": 1}, strict=False + ) + assert trace.schema_version == "4" + assert "brand_new_key" in caplog.text + + def test_strict_is_read_from_the_environment( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("GRAQLE_TRACE_SCHEMA_STRICT", "true") + with pytest.raises(ValueError): + read_trace({**_V1, "schema_version": "4"}) + + def test_strict_defaults_off(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("GRAQLE_TRACE_SCHEMA_STRICT", raising=False) + assert read_trace({**_V1, "schema_version": "4"}).schema_version == "4" + + def test_invalid_record_raises_value_error(self) -> None: + with pytest.raises(ValueError, match="failed v2 validation"): + read_trace({"schema_version": "2", "tool_name": "t"}) + + +class TestAC15LeafUnchanged: + def test_leaf_hash_fields_tuple_is_untouched(self) -> None: + assert LEAF_HASH_FIELDS == ( + "proof_format_version", + "record_id", + "content_hash", + "timestamp_unix", + "governance_metadata", + ) + + def test_leaf_input_version_is_untouched(self) -> None: + assert LEAF_INPUT_VERSION == "1.0.0" + + +class TestDecisionEnumUntouched: + def test_decision_still_has_exactly_three_members(self) -> None: + # Review checklist (1): PR-012b must not extend Decision. + assert [d.value for d in Decision] == ["PASS", "BLOCK", "WARN"]