From a728c8686e4db5525be316535288ab2352a9709d Mon Sep 17 00:00:00 2001 From: quantamixsol Date: Tue, 22 Sep 2026 08:51:26 +0200 Subject: [PATCH 1/4] feat(dag-2026): port v0.84.1 CR-012 foundation to public Ports the merged DAG-2026 v0.84.1 release (private PRs #349, #352, #353) to the public SDK. Content is transferred byte-identically from the reviewed private tree rather than regenerated, so the artifact here is the artifact Research cleared. Source (18 files): - assurance: outcomes, reason_codes, verdict (new); __init__, settings - governance: trace_schema (v2 -> v3), trace_store - core: exceptions, governance, governance_thresholds (new) - activation: cypher_activation, factory_helpers, multi_signal - connectors/neo4j, config/exceptions, cli/commands/doctor - CHANGELOG.md, RESEARCH.md (new) Tests (13 files) accompany the source: the schema v2 -> v3 bump requires the three v2 suites to move with it, plus test_trace_schema_v3_compat and the new assurance/core/activation/cli coverage. .gitattributes pins tests/fixtures/*.json to LF. The AC-9 golden fixture is byte-hash-asserted; with core.autocrlf=true a Windows checkout would rewrite it to CRLF and fail the hash on a fresh clone. Version 0.84.0 -> 0.84.1, with all 5 manifests synced. The CR-CI-001 suppression ceiling guard is deliberately NOT ported: public carries 37 --ignore flags to private's 23 (public predates Phase 2). That is separate CR-CI-001 work and gets its own PR. GRAQLE_DAG_ENABLED remains false by default. Verification: affected-module suites compared against origin/master with an identical invocation. Base 12-13 failed / 2094 passed; branch 13 failed / 2232 passed. Set difference in the regression direction is EMPTY -- all 13 failures reproduce on base (10 gate_install, 2 timing-dependent latency benchmarks, 1 local-embedding dimension mismatch). Zero regressions. Co-Authored-By: Claude Opus 5 (1M context) --- .agents/plugins/marketplace.json | 2 +- .claude-plugin/marketplace.json | 2 +- .gitattributes | 6 + .github/workflows/ci.yml | 7 + .importlinter | 29 + CHANGELOG.md | 39 +- RESEARCH.md | 178 +++ graqle/__version__.py | 2 +- graqle/activation/cypher_activation.py | 158 +- graqle/activation/factory_helpers.py | 6 + graqle/activation/multi_signal.py | 141 +- graqle/assurance/__init__.py | 44 +- graqle/assurance/outcomes.py | 96 ++ graqle/assurance/reason_codes.py | 351 ++++ graqle/assurance/settings.py | 23 +- graqle/assurance/verdict.py | 151 ++ graqle/cli/commands/doctor.py | 72 + graqle/config/exceptions.py | 22 + graqle/connectors/neo4j.py | 72 +- graqle/core/exceptions.py | 62 + graqle/core/governance.py | 12 +- graqle/core/governance_thresholds.py | 128 ++ graqle/governance/trace_schema.py | 171 +- graqle/governance/trace_store.py | 8 + .../graqle/.claude-plugin/plugin.json | 2 +- .../codex/graqle/.codex-plugin/plugin.json | 2 +- pyproject.toml | 3 +- server.json | 4 +- tests/fixtures/gate_result_golden_v0830.json | 1418 +++++++++++++++++ .../test_vector_index_missing.py | 269 ++++ .../test_gateverdictref_shape_parity.py | 54 + tests/test_assurance/test_import_direction.py | 56 + tests/test_assurance/test_outcomes.py | 115 ++ tests/test_assurance/test_reason_codes.py | 175 ++ tests/test_assurance/test_verdict.py | 174 ++ .../test_doctor_neo4j_activation_line.py | 216 +++ .../test_governance_threshold_resolver.py | 205 +++ .../test_core/test_neo4j_loader_pops_type.py | 165 ++ .../test_governed_trace_regression_v2.py | 22 +- .../test_governed_trace_schema_v2.py | 16 +- tests/test_governance/test_rdf_mapper_v2.py | 16 +- .../test_trace_schema_v3_compat.py | 162 ++ 42 files changed, 4764 insertions(+), 92 deletions(-) create mode 100644 .gitattributes create mode 100644 .importlinter create mode 100644 RESEARCH.md create mode 100644 graqle/assurance/outcomes.py create mode 100644 graqle/assurance/reason_codes.py create mode 100644 graqle/assurance/verdict.py create mode 100644 graqle/core/governance_thresholds.py create mode 100644 tests/fixtures/gate_result_golden_v0830.json create mode 100644 tests/test_activation/test_vector_index_missing.py create mode 100644 tests/test_assurance/test_gateverdictref_shape_parity.py create mode 100644 tests/test_assurance/test_import_direction.py create mode 100644 tests/test_assurance/test_outcomes.py create mode 100644 tests/test_assurance/test_reason_codes.py create mode 100644 tests/test_assurance/test_verdict.py create mode 100644 tests/test_cli/test_doctor_neo4j_activation_line.py create mode 100644 tests/test_core/test_governance_threshold_resolver.py create mode 100644 tests/test_core/test_neo4j_loader_pops_type.py create mode 100644 tests/test_governance/test_trace_schema_v3_compat.py 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/RESEARCH.md b/RESEARCH.md new file mode 100644 index 00000000..73da21a4 --- /dev/null +++ b/RESEARCH.md @@ -0,0 +1,178 @@ +# Research + +GraQle is built on a research programme, not a feature backlog. This page says what we are +investigating, what we have proven, and — most importantly — **what we have not**. + +We publish this because the difference between a shipped mechanism and a research direction is +exactly the thing most AI infrastructure documentation blurs. If a claim on this page is labelled +*research*, it means you should not buy on it yet. + +## How to read the status labels + +| Label | What it means | +|---|---| +| **Shipped** | Implemented, tested, and you can exercise it today | +| **Architecture thesis** | The system is built as though this is true; the benchmark that would demonstrate it has not reported | +| **Research** | An open question with a hypothesis and a stop condition. It may fail | +| **Not claimed** | We are deliberately not asserting this, and you should be sceptical of anyone who does | + +A programme can be rejected. Every one below has a stop condition, and a result that contradicts +our own thesis is a valid outcome we will report rather than quietly requalify. + +--- + +## What is shipped, and what that entitles you to conclude + +| Capability | Status | What you can verify yourself | +|---|---|---| +| Architecture-aware reasoning over a persistent graph | **Shipped** | `graq scan`, then ask a cross-file impact question | +| Model-agnostic operation | **Shipped** (compatibility) | 13 backends plus custom; swap and re-run | +| Persistent lessons across sessions | **Shipped** | Teach a lesson; start a new session; it recalls | +| Confidence and evidence on answers | **Shipped** | Every answer carries confidence and an evidence trail | +| Fail-visible retrieval | **Shipped (0.84.1)** | A missing vector index now raises a typed error instead of silently degrading | +| Governed write paths, decision attestation | **Shipped** | Write gates, runtime attestation, cryptographic audit trail | + +**Model-agnostic operation is compatibility, not yet continuity.** The stronger claim — that +evidence selection and gate state survive a model swap — is Programme H below, and it is +unproven. We are careful about this distinction because it is the one most worth getting right. + +--- + +## The Decision Assurance Gate + +A confidence score is a single number, and a single number cannot express *why* something should +not proceed. The Decision Assurance Gate replaces scalar gating with an explicit verdict: + +``` +EXECUTE · REPLAN · HOLD · REJECT · ESCALATE +``` + +driven by non-compensatory hard gates (a critical failure cannot be outvoted by strong scores +elsewhere), a multi-dimension confidence vector, and a hash-chained provenance record. + +**Status: foundation shipped in 0.84.1, behind `GRAQLE_DAG_ENABLED` (off by default).** The +vocabulary, typed settings and verdict schema exist. Hard gates, the confidence vector, the +trajectory monitor and the provenance chain are specified and not yet built. The flag stays off +until the evaluation suite passes with recorded results. + +--- + +## Open research programmes + +### A · Trusted state, not memory · *Architecture thesis* + +Memory can faithfully preserve something wrong or superseded. Trusted state requires knowing +which claim is authoritative, when it was valid, and what replaced it. + +**Hypothesis:** a graph carrying authority, temporal validity and contradiction resolution answers +"what is true *now*" more accurately than retrieval over the same corpus. +**Stop condition:** if it does not beat a retrieval baseline on current-answer accuracy, the +trusted-state framing is withdrawn from public claims. + +### B · Evidence independence · *Research* + +Three agents agreeing is not three pieces of evidence if they share a model family, a prompt +template, or a source. Confidence must discount correlated failure. + +**Status:** the correlation discount is specified — agreement between generators sharing a model +family or prompt template is counted once, not three times. **Calibration has not reported.** +**Boundary we hold ourselves to:** shared identity proves correlation; *different* identity does +**not** prove independence. Two models trained on overlapping corpora remain correlated in ways +we cannot currently detect. So this measures *detected* independence, and we will not describe it +otherwise. + +### C · Capability provenance and skill poisoning · *Research* + +Agent skills and plugins are a software supply chain. The same trust logic we apply to facts +should apply to tools *before* they execute. + +**Hypothesis:** capability manifests plus provenance gating catch untrusted skills at an +acceptable false-positive rate. **Not started.** + +### D · Agent identity and authority · *Research* + +An agent may swap models and keep its organisational role. Authority should expire, and revoking +a parent's permission must reach its subagents. **Not started**, with a deliberate boundary: we +are not rebuilding identity management, only the authority graph over it. + +### E · Minimal sufficient context · *Research* + +More context is not more intelligence. How small can the activated subgraph be while still +answering correctly? **Not started.** + +### F · Change intelligence · *Research* + +AI makes code cheap to produce, so validation becomes the bottleneck. Can review cost stay +sublinear as generated change volume rises an order of magnitude? **Not started.** + +### G · Decision-grade intelligence · *Research* + +Moving from answering a question to structuring the decision: options, assumptions, +reversibility, and what would change the answer. **Not started.** + +### H · Model portability · *Research · highest near-term priority* + +**Hypothesis:** for a fixed graph and question set, the evidence an answer rests on — activated +nodes, recalled lessons, evidence pointers, gate outcome — is substantially invariant across +model backends, while the answer's wording is not. + +Measured against a within-backend baseline, because cross-model agreement means nothing without +knowing how much a single model varies against itself. One of the three backends is deliberately +a small local model: two frontier models agreeing would prove very little. + +**Stop condition:** if cross-backend agreement is no better than within-backend variance, we +narrow the README to compatibility and say so here. + +### I · Safe institutional learning · *Research* + +One success is not a universal rule. Lessons need promotion states, expiry, and detection of +poisoned memory. **Not started.** + +--- + +## What we do not claim + +- **Self-improving organisational intelligence.** Not claimed. Learning safety and promotion rules + are unproven, and the phrase oversells what any current system does. +- **Trusted organisational state as a delivered guarantee.** It is an architecture thesis until + Programme A's benchmark reports. +- **Evidence-independent confidence.** Specified, not calibrated. +- **That more agents produce better answers.** Multi-agent debate is a reasoning mechanism, not a + proof of truth. We removed language implying otherwise. +- **DAG-governed autonomous execution.** Not claimed until replay and rollback are implemented and + demonstrated. + +--- + +## How we work + +Rules we adopted after getting things wrong, kept because they cost us something: + +**Every number resolves to three artefacts** — a dataset file, a run log, and a results file. We +found projected figures presented as measured in our own draft papers, and simulation output +treated as live results in another. "Preserved from a prior draft" is not provenance. + +**Absence of a failure signal is not evidence of correctness.** We shipped a retrieval path that +silently degraded when a vector index was missing, and it went undetected for months because +nothing failed loudly. The same pattern later turned up in our own CI, where suppressed test files +kept the build green while real defects hid behind the suppression. Both are now fixed, and the +principle generalises: a check that cannot fail is not a check. + +**Research may reject the thesis.** Every programme above has a stop condition written before the +data arrives. + +**Claims map to mechanisms.** Anything reaching the README links to a shipped capability or a +benchmark artefact. That is why this page has so many *Research* labels. + +--- + +## Reproducing our work + +Benchmark methods and results are published as they clear the artefacts gate. Where a programme +above says *not started*, there is nothing to reproduce yet, and we would rather say that than +publish a figure we cannot trace. + +--- + +*Status labels current as of the release this file ships with. Programme status changes are +recorded in `CHANGELOG.md`, not silently edited here.* 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..f50f9507 --- /dev/null +++ b/graqle/assurance/reason_codes.py @@ -0,0 +1,351 @@ +"""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") + + +_SEED: tuple[ReasonCode, ...] = ( + # HG-01 mandatory policy (CR-013) + ReasonCode( + "DAG-HG01-POLICY_MISSING", + Severity.CRITICAL, + "HG-01", + "Attach the governing policy node in scope before retrying.", + "0.85.0", + ), + ReasonCode( + "DAG-HG01-POLICY_EXPIRED", + Severity.CRITICAL, + "HG-01", + "Policy valid_until has passed; obtain the superseding policy.", + "0.85.0", + ), + ReasonCode( + "DAG-HG01-POLICY_UNRESOLVED", + Severity.CRITICAL, + "HG-01", + "Policy has open CONTRADICTS without COEXISTS_WITH resolution.", + "0.85.0", + ), + ReasonCode( + "DAG-HG01-POLICY_VIOLATED", + Severity.CRITICAL, + "HG-01", + "Action conflicts with a resolved policy clause; replan.", + "0.85.0", + ), + # HG-02 invalidated evidence (OQ-4: FAIL, resolvable, REPLAN-eligible) + ReasonCode( + "DAG-HG02-EVIDENCE_INVALIDATED", + Severity.FAIL, + "HG-02", + "Depends on INVALIDATED evidence; wait for recomputation.", + "0.85.0", + ), + ReasonCode( + "DAG-HG02-RECOMPUTE_INCOMPLETE", + Severity.FAIL, + "HG-02", + "Recomputation queue has pending items for implicated nodes.", + "0.85.0", + ), + # HG-03 provenance + ReasonCode( + "DAG-HG03-PROVENANCE_MISSING", + Severity.CRITICAL, + "HG-03", + "High-impact claim lacks a ProvenanceEvent; capture provenance first.", + "0.85.0", + ), + # HG-04 args binding (binding computed in CR-018) + ReasonCode( + "DAG-HG04-ARGS_HASH_MISMATCH", + Severity.CRITICAL, + "HG-04", + "Invoked args differ from approved args; re-request approval.", + "0.85.0", + ), + ReasonCode( + "DAG-HG04-ARGS_HASH_ALGO_MISMATCH", + Severity.CRITICAL, + "HG-04", + "Hash algorithm differs from approval-time algorithm.", + "0.85.0", + ), + ReasonCode( + "DAG-HG04-BINDING_ABSENT", + Severity.CRITICAL, + "HG-04", + "No approved_args_hash recorded at grant time.", + "0.85.0", + ), + # HG-05 poisoning + ReasonCode( + "DAG-HG05-POISONING_DETECTED", + Severity.CRITICAL, + "HG-05", + "Poisoning severity at or above threshold; quarantine source.", + "0.85.0", + ), + # HG-06 authorization + ReasonCode( + "DAG-HG06-ACTOR_UNAUTHORIZED", + Severity.CRITICAL, + "HG-06", + "Actor role cannot approve this tier (core/rbac.py).", + "0.85.0", + ), + ReasonCode( + "DAG-HG06-RBAC_UNAVAILABLE", + Severity.CRITICAL, + "HG-06", + "RBAC check raised or timed out; fail-closed.", + "0.85.0", + ), + # HG-07 contradiction + ReasonCode( + "DAG-HG07-CONTRADICTION_UNRESOLVED", + Severity.CRITICAL, + "HG-07", + "Material CONTRADICTS edge without COEXISTS_WITH rule.", + "0.85.0", + ), + # Cap + ReasonCode( + "DAG-RP-CAP_APPLIED", + Severity.FAIL, + "CAP", + "Critical-failure cap applied after hard-gate failure.", + "0.85.0", + ), + # Confidence vector (CR-014) + ReasonCode( + "DAG-CV-BELOW_PROJECTION", + Severity.WARN, + "CV", + "Projected confidence below policy projection threshold.", + "0.85.0", + ), + ReasonCode( + "DAG-CV-CALIBRATOR_STALE", + Severity.WARN, + "CV", + "Calibrator older than TTL; recalibrate.", + "0.85.0", + ), + ReasonCode( + "DAG-CV-RAW_CAL_DIVERGENCE", + Severity.WARN, + "CV", + "Raw vs calibrated divergence exceeds bound.", + "0.85.0", + ), + ReasonCode( + "DAG-CV-DIM_MISSING", + Severity.WARN, + "CV", + "A confidence dimension could not be computed (missing data).", + "0.85.0", + ), + # Trajectory (CR-015) + ReasonCode( + "DAG-TR-RELIABILITY_LOW", + Severity.WARN, + "TR", + "Trajectory reliability below checkpoint floor.", + "0.85.0", + ), + ReasonCode( + "DAG-TR-EARLY_ERROR", + Severity.WARN, + "TR", + "Early-step error detected in trajectory history.", + "0.85.0", + ), + # Provenance (CR-018) + ReasonCode( + "DAG-PV-CHAIN_BREAK", + Severity.CRITICAL, + "PV", + "previous_event_hash does not match prior event.", + "0.85.0", + ), + ReasonCode( + "DAG-PV-SEQUENCE_GAP", + Severity.CRITICAL, + "PV", + "sequence_number gap detected.", + "0.85.0", + ), + # Replan (CR-015) + ReasonCode( + "DAG-RP-RETRY_BUDGET_EXHAUSTED", + Severity.FAIL, + "RP", + "Retry budget exhausted; escalating.", + "0.85.0", + ), + ReasonCode( + "DAG-RP-UNCHANGED_REASON", + Severity.FAIL, + "RP", + "Replan produced identical reason fingerprint; escalating.", + "0.85.0", + ), + ReasonCode( + "DAG-RP-EVALUATION_ERROR", + Severity.CRITICAL, + "RP", + "Gate evaluation raised; treated as failure (fail-closed).", + "0.85.0", + ), +) + +#: 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..250f4ede 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,7 @@ dev = [ "pytest-cov>=4.0", "mypy>=1.0", "ruff>=0.1", + "import-linter>=2.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..a8770d88 --- /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-POLICY_MISSING"], + "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..67529b39 --- /dev/null +++ b/tests/test_assurance/test_reason_codes.py @@ -0,0 +1,175 @@ +"""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): the seed-code snapshot below is a MERGE GATE. 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. +""" + +from __future__ import annotations + +import re + +import pytest +from hypothesis import given, settings +from hypothesis import strategies as st + +from graqle.assurance.reason_codes import ( + CODE_PATTERN, + REGISTRY, + ReasonCode, + Severity, + max_severity, + resolvable, + validate, +) + +# INV-RC-2 append-only snapshot. ADD to this list; never delete, never re-spell. +_SEEDED_AT_0_85_0 = { + "DAG-CV-BELOW_PROJECTION", + "DAG-CV-CALIBRATOR_STALE", + "DAG-CV-DIM_MISSING", + "DAG-CV-RAW_CAL_DIVERGENCE", + "DAG-HG01-POLICY_EXPIRED", + "DAG-HG01-POLICY_MISSING", + "DAG-HG01-POLICY_UNRESOLVED", + "DAG-HG01-POLICY_VIOLATED", + "DAG-HG02-EVIDENCE_INVALIDATED", + "DAG-HG02-RECOMPUTE_INCOMPLETE", + "DAG-HG03-PROVENANCE_MISSING", + "DAG-HG04-ARGS_HASH_ALGO_MISMATCH", + "DAG-HG04-ARGS_HASH_MISMATCH", + "DAG-HG04-BINDING_ABSENT", + "DAG-HG05-POISONING_DETECTED", + "DAG-HG06-ACTOR_UNAUTHORIZED", + "DAG-HG06-RBAC_UNAVAILABLE", + "DAG-HG07-CONTRADICTION_UNRESOLVED", + "DAG-PV-CHAIN_BREAK", + "DAG-PV-SEQUENCE_GAP", + "DAG-RP-CAP_APPLIED", + "DAG-RP-EVALUATION_ERROR", + "DAG-RP-RETRY_BUDGET_EXHAUSTED", + "DAG-RP-UNCHANGED_REASON", + "DAG-TR-EARLY_ERROR", + "DAG-TR-RELIABILITY_LOW", +} + + +class TestAppendOnly: + def test_no_seeded_code_was_removed_or_respelled(self) -> None: + missing = sorted(_SEEDED_AT_0_85_0 - set(REGISTRY)) + assert not missing, ( + f"INV-RC-2 violated — codes removed or re-spelled: {missing}. " + "Reason codes are append-only across versions." + ) + + 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_hg02_is_seeded_at_fail_per_oq4(self) -> None: + assert REGISTRY["DAG-HG02-EVIDENCE_INVALIDATED"].severity is Severity.FAIL + assert REGISTRY["DAG-HG02-RECOMPUTE_INCOMPLETE"].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) -> None: + codes = ["DAG-CV-DIM_MISSING", "DAG-TR-EARLY_ERROR", "DAG-CV-DIM_MISSING"] + assert validate(codes) == ["DAG-CV-DIM_MISSING", "DAG-TR-EARLY_ERROR"] + + def test_max_severity(self) -> None: + assert ( + max_severity(["DAG-CV-DIM_MISSING", "DAG-HG01-POLICY_MISSING"]) + is Severity.CRITICAL + ) + assert max_severity([]) is None + + def test_resolvable_follows_ruling_r2(self) -> None: + # INFO/WARN -> resolvable; FAIL with a hint -> resolvable; CRITICAL -> never. + assert resolvable("DAG-CV-BELOW_PROJECTION") is True + assert resolvable("DAG-HG02-EVIDENCE_INVALIDATED") is True + assert resolvable("DAG-HG01-POLICY_MISSING") 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..47390324 --- /dev/null +++ b/tests/test_assurance/test_verdict.py @@ -0,0 +1,174 @@ +"""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 + +import pytest +from pydantic import ValidationError + +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) -> None: + v = _verdict( + reason_codes=[ + "DAG-CV-DIM_MISSING", + "DAG-TR-EARLY_ERROR", + "DAG-CV-DIM_MISSING", + ] + ) + assert v.reason_codes == ["DAG-CV-DIM_MISSING", "DAG-TR-EARLY_ERROR"] + + 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-POLICY_MISSING"], + 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..8fcda85d --- /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-POLICY_MISSING"], + 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-POLICY_MISSING"] + 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"] From ca11e9cf2adf4945136ba3edfa04107e3b4c7ea9 Mon Sep 17 00:00:00 2001 From: quantamixsol Date: Tue, 22 Sep 2026 09:00:57 +0200 Subject: [PATCH 2/4] fix(deps): declare hypothesis in dev extras CI failed with ModuleNotFoundError: No module named 'hypothesis' while importing tests/test_assurance/test_reason_codes.py. Private declares hypothesis>=6.0; public did not. This is pre-existing on public -- test_core/test_types_exhaustive.py, test_intent/test_kg_routing.py, test_plugins/test_mcp_predict.py and both test_tamper_evidence suites already import hypothesis without declaring it. Those modules sit behind --ignore flags, so the gap stayed hidden until this port added a collected test that needs it. It passed locally because hypothesis was already installed on the dev machine -- a green local pytest hiding a missing optional dep. Audited the rest of the ported tests: hypothesis was the only undeclared import. Co-Authored-By: Claude Opus 5 (1M context) --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 250f4ede..5a8fec47 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -166,6 +166,7 @@ dev = [ "mypy>=1.0", "ruff>=0.1", "import-linter>=2.0", + "hypothesis>=6.0", "coverage>=7.0", "httpx>=0.25", "boto3>=1.28", From 4af1b0e68b676ef5d7cd7f6c043d4426d8610d44 Mon Sep 17 00:00:00 2001 From: quantamixsol Date: Tue, 22 Sep 2026 22:20:29 +0200 Subject: [PATCH 3/4] revert: hold RESEARCH.md out of the public port (patent-before-publish) Research Team REQUEST CHANGES on PR #263, item 1. RESEARCH.md's 178 public lines describe the DAG-2026 gate design in the open: the five-verdict taxonomy, non-compensatory hard gates, a multi-dimension confidence vector and a hash-chained provenance record. None of that is filed. DAG-PUB-01 section 6.1 (authorise Group S CIP drafting) is an open sole-approver decision, and the programme rule is no public manuscript before CIP receipt. Under EPC Art. 54 an applicant's own publication is prior art against its own later application, with no grace period. The Senior held two research notes on 19 September for this same exposure. The private merge of #353 approved RESEARCH.md for the PRIVATE repo only. A private-merge approval never covers public disclosure -- that inference is what produced this defect, and it is now a recorded lesson. RESEARCH.md ships in its own PR after the Group S decision or written counsel clearance. The README section 15 link PR stays blocked behind the same hold. Trade-secret review was clean and is unaffected: this is a separate axis. Co-Authored-By: Claude Opus 5 (1M context) --- RESEARCH.md | 178 ---------------------------------------------------- 1 file changed, 178 deletions(-) delete mode 100644 RESEARCH.md diff --git a/RESEARCH.md b/RESEARCH.md deleted file mode 100644 index 73da21a4..00000000 --- a/RESEARCH.md +++ /dev/null @@ -1,178 +0,0 @@ -# Research - -GraQle is built on a research programme, not a feature backlog. This page says what we are -investigating, what we have proven, and — most importantly — **what we have not**. - -We publish this because the difference between a shipped mechanism and a research direction is -exactly the thing most AI infrastructure documentation blurs. If a claim on this page is labelled -*research*, it means you should not buy on it yet. - -## How to read the status labels - -| Label | What it means | -|---|---| -| **Shipped** | Implemented, tested, and you can exercise it today | -| **Architecture thesis** | The system is built as though this is true; the benchmark that would demonstrate it has not reported | -| **Research** | An open question with a hypothesis and a stop condition. It may fail | -| **Not claimed** | We are deliberately not asserting this, and you should be sceptical of anyone who does | - -A programme can be rejected. Every one below has a stop condition, and a result that contradicts -our own thesis is a valid outcome we will report rather than quietly requalify. - ---- - -## What is shipped, and what that entitles you to conclude - -| Capability | Status | What you can verify yourself | -|---|---|---| -| Architecture-aware reasoning over a persistent graph | **Shipped** | `graq scan`, then ask a cross-file impact question | -| Model-agnostic operation | **Shipped** (compatibility) | 13 backends plus custom; swap and re-run | -| Persistent lessons across sessions | **Shipped** | Teach a lesson; start a new session; it recalls | -| Confidence and evidence on answers | **Shipped** | Every answer carries confidence and an evidence trail | -| Fail-visible retrieval | **Shipped (0.84.1)** | A missing vector index now raises a typed error instead of silently degrading | -| Governed write paths, decision attestation | **Shipped** | Write gates, runtime attestation, cryptographic audit trail | - -**Model-agnostic operation is compatibility, not yet continuity.** The stronger claim — that -evidence selection and gate state survive a model swap — is Programme H below, and it is -unproven. We are careful about this distinction because it is the one most worth getting right. - ---- - -## The Decision Assurance Gate - -A confidence score is a single number, and a single number cannot express *why* something should -not proceed. The Decision Assurance Gate replaces scalar gating with an explicit verdict: - -``` -EXECUTE · REPLAN · HOLD · REJECT · ESCALATE -``` - -driven by non-compensatory hard gates (a critical failure cannot be outvoted by strong scores -elsewhere), a multi-dimension confidence vector, and a hash-chained provenance record. - -**Status: foundation shipped in 0.84.1, behind `GRAQLE_DAG_ENABLED` (off by default).** The -vocabulary, typed settings and verdict schema exist. Hard gates, the confidence vector, the -trajectory monitor and the provenance chain are specified and not yet built. The flag stays off -until the evaluation suite passes with recorded results. - ---- - -## Open research programmes - -### A · Trusted state, not memory · *Architecture thesis* - -Memory can faithfully preserve something wrong or superseded. Trusted state requires knowing -which claim is authoritative, when it was valid, and what replaced it. - -**Hypothesis:** a graph carrying authority, temporal validity and contradiction resolution answers -"what is true *now*" more accurately than retrieval over the same corpus. -**Stop condition:** if it does not beat a retrieval baseline on current-answer accuracy, the -trusted-state framing is withdrawn from public claims. - -### B · Evidence independence · *Research* - -Three agents agreeing is not three pieces of evidence if they share a model family, a prompt -template, or a source. Confidence must discount correlated failure. - -**Status:** the correlation discount is specified — agreement between generators sharing a model -family or prompt template is counted once, not three times. **Calibration has not reported.** -**Boundary we hold ourselves to:** shared identity proves correlation; *different* identity does -**not** prove independence. Two models trained on overlapping corpora remain correlated in ways -we cannot currently detect. So this measures *detected* independence, and we will not describe it -otherwise. - -### C · Capability provenance and skill poisoning · *Research* - -Agent skills and plugins are a software supply chain. The same trust logic we apply to facts -should apply to tools *before* they execute. - -**Hypothesis:** capability manifests plus provenance gating catch untrusted skills at an -acceptable false-positive rate. **Not started.** - -### D · Agent identity and authority · *Research* - -An agent may swap models and keep its organisational role. Authority should expire, and revoking -a parent's permission must reach its subagents. **Not started**, with a deliberate boundary: we -are not rebuilding identity management, only the authority graph over it. - -### E · Minimal sufficient context · *Research* - -More context is not more intelligence. How small can the activated subgraph be while still -answering correctly? **Not started.** - -### F · Change intelligence · *Research* - -AI makes code cheap to produce, so validation becomes the bottleneck. Can review cost stay -sublinear as generated change volume rises an order of magnitude? **Not started.** - -### G · Decision-grade intelligence · *Research* - -Moving from answering a question to structuring the decision: options, assumptions, -reversibility, and what would change the answer. **Not started.** - -### H · Model portability · *Research · highest near-term priority* - -**Hypothesis:** for a fixed graph and question set, the evidence an answer rests on — activated -nodes, recalled lessons, evidence pointers, gate outcome — is substantially invariant across -model backends, while the answer's wording is not. - -Measured against a within-backend baseline, because cross-model agreement means nothing without -knowing how much a single model varies against itself. One of the three backends is deliberately -a small local model: two frontier models agreeing would prove very little. - -**Stop condition:** if cross-backend agreement is no better than within-backend variance, we -narrow the README to compatibility and say so here. - -### I · Safe institutional learning · *Research* - -One success is not a universal rule. Lessons need promotion states, expiry, and detection of -poisoned memory. **Not started.** - ---- - -## What we do not claim - -- **Self-improving organisational intelligence.** Not claimed. Learning safety and promotion rules - are unproven, and the phrase oversells what any current system does. -- **Trusted organisational state as a delivered guarantee.** It is an architecture thesis until - Programme A's benchmark reports. -- **Evidence-independent confidence.** Specified, not calibrated. -- **That more agents produce better answers.** Multi-agent debate is a reasoning mechanism, not a - proof of truth. We removed language implying otherwise. -- **DAG-governed autonomous execution.** Not claimed until replay and rollback are implemented and - demonstrated. - ---- - -## How we work - -Rules we adopted after getting things wrong, kept because they cost us something: - -**Every number resolves to three artefacts** — a dataset file, a run log, and a results file. We -found projected figures presented as measured in our own draft papers, and simulation output -treated as live results in another. "Preserved from a prior draft" is not provenance. - -**Absence of a failure signal is not evidence of correctness.** We shipped a retrieval path that -silently degraded when a vector index was missing, and it went undetected for months because -nothing failed loudly. The same pattern later turned up in our own CI, where suppressed test files -kept the build green while real defects hid behind the suppression. Both are now fixed, and the -principle generalises: a check that cannot fail is not a check. - -**Research may reject the thesis.** Every programme above has a stop condition written before the -data arrives. - -**Claims map to mechanisms.** Anything reaching the README links to a shipped capability or a -benchmark artefact. That is why this page has so many *Research* labels. - ---- - -## Reproducing our work - -Benchmark methods and results are published as they clear the artefacts gate. Where a programme -above says *not started*, there is nothing to reproduce yet, and we would rather say that than -publish a figure we cannot trace. - ---- - -*Status labels current as of the release this file ships with. Programme status changes are -recorded in `CHANGELOG.md`, not silently edited here.* From fa117f272194502566d31302ed34370b4f688344 Mon Sep 17 00:00:00 2001 From: quantamixsol Date: Wed, 23 Sep 2026 07:24:36 +0200 Subject: [PATCH 4/4] refactor(assurance): ship an empty reason-code seed publicly (patent hold) Research Team ruling on PR #263 item 2: option (a) -- remove all 26 seed entries from the public reason_codes.py and ship the machinery with an empty _SEED. Option (b) (keep the 15 hard-gate codes) was rejected because HG01-HG07 name the seven gates and their failure modes -- policy, invalidated evidence, provenance, args binding, poisoning, authorisation, contradiction -- and hard gates and args binding are both on the unfiled claim list. Keeping them would publish the gate architecture while withholding the confidence-vector labels, which is the wrong way round. Removing everything is the only boundary that needs no judgement about which family is safer. Research also corrected their own instruction: there is no set of 12. CR-012 section 4.2 lists exactly these 26 codes; the "12" came from a grep that matched only twelve identifiers by quoting style. The file was correct on private. Ships publicly: ReasonCode, Severity, CODE_PATTERN, validate, max_severity, resolvable, REGISTRY, and an empty seed. The grammar and INV-RC-1..3 ship; the vocabulary does not. INV-RC-2 (append-only, monotone since_version) is intact -- seeding at 0.85.0 after an empty 0.84.1 is a plain append. Tests, same commit: - test_reason_codes.py: the 26-id append-only snapshot is removed (it republished the full vocabulary in the test file). Registry-dependent behaviour now runs against a local synthetic fixture registry via monkeypatch, so it does not depend on what the shipped seed contains. Grammar, construction, invariant and fuzz tests are unchanged. - test_verdict.py: dedup test uses a synthetic fixture registry. - test_verdict.py, test_gateverdictref_shape_parity.py, test_trace_schema_v3_compat.py: DAG-HG01-POLICY_MISSING -> DAG-HG01-CC. These three passed either way (GateVerdictRef does not validate against the registry), so tests alone would not have caught the surviving identifier -- it was found by grepping all 26 ids across the branch. No file comment records the hold, per Research: a "withheld pending patent review" note is itself a disclosure. The seed docstring is a neutral design statement only. Byte-identity delta from private: this one source file plus the four test files. Verified: all 26 identifiers absent from every tracked file on the branch. Affected suites 13 failed / 2232 passed -- unchanged from before this commit, and all 13 reproduce on origin/master. Co-Authored-By: Claude Opus 5 (1M context) --- graqle/assurance/reason_codes.py | 201 +----------------- .../test_gateverdictref_shape_parity.py | 2 +- tests/test_assurance/test_reason_codes.py | 104 +++++---- tests/test_assurance/test_verdict.py | 27 ++- .../test_trace_schema_v3_compat.py | 4 +- 5 files changed, 78 insertions(+), 260 deletions(-) diff --git a/graqle/assurance/reason_codes.py b/graqle/assurance/reason_codes.py index f50f9507..bd8c9853 100644 --- a/graqle/assurance/reason_codes.py +++ b/graqle/assurance/reason_codes.py @@ -91,202 +91,11 @@ def __post_init__(self) -> None: raise ValueError("remediation_hint exceeds the length cap") -_SEED: tuple[ReasonCode, ...] = ( - # HG-01 mandatory policy (CR-013) - ReasonCode( - "DAG-HG01-POLICY_MISSING", - Severity.CRITICAL, - "HG-01", - "Attach the governing policy node in scope before retrying.", - "0.85.0", - ), - ReasonCode( - "DAG-HG01-POLICY_EXPIRED", - Severity.CRITICAL, - "HG-01", - "Policy valid_until has passed; obtain the superseding policy.", - "0.85.0", - ), - ReasonCode( - "DAG-HG01-POLICY_UNRESOLVED", - Severity.CRITICAL, - "HG-01", - "Policy has open CONTRADICTS without COEXISTS_WITH resolution.", - "0.85.0", - ), - ReasonCode( - "DAG-HG01-POLICY_VIOLATED", - Severity.CRITICAL, - "HG-01", - "Action conflicts with a resolved policy clause; replan.", - "0.85.0", - ), - # HG-02 invalidated evidence (OQ-4: FAIL, resolvable, REPLAN-eligible) - ReasonCode( - "DAG-HG02-EVIDENCE_INVALIDATED", - Severity.FAIL, - "HG-02", - "Depends on INVALIDATED evidence; wait for recomputation.", - "0.85.0", - ), - ReasonCode( - "DAG-HG02-RECOMPUTE_INCOMPLETE", - Severity.FAIL, - "HG-02", - "Recomputation queue has pending items for implicated nodes.", - "0.85.0", - ), - # HG-03 provenance - ReasonCode( - "DAG-HG03-PROVENANCE_MISSING", - Severity.CRITICAL, - "HG-03", - "High-impact claim lacks a ProvenanceEvent; capture provenance first.", - "0.85.0", - ), - # HG-04 args binding (binding computed in CR-018) - ReasonCode( - "DAG-HG04-ARGS_HASH_MISMATCH", - Severity.CRITICAL, - "HG-04", - "Invoked args differ from approved args; re-request approval.", - "0.85.0", - ), - ReasonCode( - "DAG-HG04-ARGS_HASH_ALGO_MISMATCH", - Severity.CRITICAL, - "HG-04", - "Hash algorithm differs from approval-time algorithm.", - "0.85.0", - ), - ReasonCode( - "DAG-HG04-BINDING_ABSENT", - Severity.CRITICAL, - "HG-04", - "No approved_args_hash recorded at grant time.", - "0.85.0", - ), - # HG-05 poisoning - ReasonCode( - "DAG-HG05-POISONING_DETECTED", - Severity.CRITICAL, - "HG-05", - "Poisoning severity at or above threshold; quarantine source.", - "0.85.0", - ), - # HG-06 authorization - ReasonCode( - "DAG-HG06-ACTOR_UNAUTHORIZED", - Severity.CRITICAL, - "HG-06", - "Actor role cannot approve this tier (core/rbac.py).", - "0.85.0", - ), - ReasonCode( - "DAG-HG06-RBAC_UNAVAILABLE", - Severity.CRITICAL, - "HG-06", - "RBAC check raised or timed out; fail-closed.", - "0.85.0", - ), - # HG-07 contradiction - ReasonCode( - "DAG-HG07-CONTRADICTION_UNRESOLVED", - Severity.CRITICAL, - "HG-07", - "Material CONTRADICTS edge without COEXISTS_WITH rule.", - "0.85.0", - ), - # Cap - ReasonCode( - "DAG-RP-CAP_APPLIED", - Severity.FAIL, - "CAP", - "Critical-failure cap applied after hard-gate failure.", - "0.85.0", - ), - # Confidence vector (CR-014) - ReasonCode( - "DAG-CV-BELOW_PROJECTION", - Severity.WARN, - "CV", - "Projected confidence below policy projection threshold.", - "0.85.0", - ), - ReasonCode( - "DAG-CV-CALIBRATOR_STALE", - Severity.WARN, - "CV", - "Calibrator older than TTL; recalibrate.", - "0.85.0", - ), - ReasonCode( - "DAG-CV-RAW_CAL_DIVERGENCE", - Severity.WARN, - "CV", - "Raw vs calibrated divergence exceeds bound.", - "0.85.0", - ), - ReasonCode( - "DAG-CV-DIM_MISSING", - Severity.WARN, - "CV", - "A confidence dimension could not be computed (missing data).", - "0.85.0", - ), - # Trajectory (CR-015) - ReasonCode( - "DAG-TR-RELIABILITY_LOW", - Severity.WARN, - "TR", - "Trajectory reliability below checkpoint floor.", - "0.85.0", - ), - ReasonCode( - "DAG-TR-EARLY_ERROR", - Severity.WARN, - "TR", - "Early-step error detected in trajectory history.", - "0.85.0", - ), - # Provenance (CR-018) - ReasonCode( - "DAG-PV-CHAIN_BREAK", - Severity.CRITICAL, - "PV", - "previous_event_hash does not match prior event.", - "0.85.0", - ), - ReasonCode( - "DAG-PV-SEQUENCE_GAP", - Severity.CRITICAL, - "PV", - "sequence_number gap detected.", - "0.85.0", - ), - # Replan (CR-015) - ReasonCode( - "DAG-RP-RETRY_BUDGET_EXHAUSTED", - Severity.FAIL, - "RP", - "Retry budget exhausted; escalating.", - "0.85.0", - ), - ReasonCode( - "DAG-RP-UNCHANGED_REASON", - Severity.FAIL, - "RP", - "Replan produced identical reason fingerprint; escalating.", - "0.85.0", - ), - ReasonCode( - "DAG-RP-EVALUATION_ERROR", - Severity.CRITICAL, - "RP", - "Gate evaluation raised; treated as failure (fail-closed).", - "0.85.0", - ), -) +#: 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). diff --git a/tests/test_assurance/test_gateverdictref_shape_parity.py b/tests/test_assurance/test_gateverdictref_shape_parity.py index a8770d88..eef3bd84 100644 --- a/tests/test_assurance/test_gateverdictref_shape_parity.py +++ b/tests/test_assurance/test_gateverdictref_shape_parity.py @@ -44,7 +44,7 @@ def test_a_verdict_ref_crosses_the_boundary_by_value(self) -> None: payload = { "verdict_schema_version": "1", "outcome": "REJECT", - "reason_codes": ["DAG-HG01-POLICY_MISSING"], + "reason_codes": ["DAG-HG01-CC"], "decisive_rule": "HG-01", "cap_applied": True, "inputs_hash": "sha256:" + "a" * 64, diff --git a/tests/test_assurance/test_reason_codes.py b/tests/test_assurance/test_reason_codes.py index 67529b39..8366eff1 100644 --- a/tests/test_assurance/test_reason_codes.py +++ b/tests/test_assurance/test_reason_codes.py @@ -4,19 +4,27 @@ REGISTRY is immutable; the grammar accepts exactly the specified language (Hypothesis fuzz). -INV-RC-2 (append-only): the seed-code snapshot below is a MERGE GATE. 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. +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, @@ -27,44 +35,30 @@ validate, ) -# INV-RC-2 append-only snapshot. ADD to this list; never delete, never re-spell. -_SEEDED_AT_0_85_0 = { - "DAG-CV-BELOW_PROJECTION", - "DAG-CV-CALIBRATOR_STALE", - "DAG-CV-DIM_MISSING", - "DAG-CV-RAW_CAL_DIVERGENCE", - "DAG-HG01-POLICY_EXPIRED", - "DAG-HG01-POLICY_MISSING", - "DAG-HG01-POLICY_UNRESOLVED", - "DAG-HG01-POLICY_VIOLATED", - "DAG-HG02-EVIDENCE_INVALIDATED", - "DAG-HG02-RECOMPUTE_INCOMPLETE", - "DAG-HG03-PROVENANCE_MISSING", - "DAG-HG04-ARGS_HASH_ALGO_MISMATCH", - "DAG-HG04-ARGS_HASH_MISMATCH", - "DAG-HG04-BINDING_ABSENT", - "DAG-HG05-POISONING_DETECTED", - "DAG-HG06-ACTOR_UNAUTHORIZED", - "DAG-HG06-RBAC_UNAVAILABLE", - "DAG-HG07-CONTRADICTION_UNRESOLVED", - "DAG-PV-CHAIN_BREAK", - "DAG-PV-SEQUENCE_GAP", - "DAG-RP-CAP_APPLIED", - "DAG-RP-EVALUATION_ERROR", - "DAG-RP-RETRY_BUDGET_EXHAUSTED", - "DAG-RP-UNCHANGED_REASON", - "DAG-TR-EARLY_ERROR", - "DAG-TR-RELIABILITY_LOW", -} +# 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_no_seeded_code_was_removed_or_respelled(self) -> None: - missing = sorted(_SEEDED_AT_0_85_0 - set(REGISTRY)) - assert not missing, ( - f"INV-RC-2 violated — codes removed or re-spelled: {missing}. " - "Reason codes are append-only across versions." - ) + 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): @@ -85,9 +79,14 @@ def test_hard_gate_codes_are_never_advisory(self) -> None: ] assert not advisory - def test_hg02_is_seeded_at_fail_per_oq4(self) -> None: - assert REGISTRY["DAG-HG02-EVIDENCE_INVALIDATED"].severity is Severity.FAIL - assert REGISTRY["DAG-HG02-RECOMPUTE_INCOMPLETE"].severity is Severity.FAIL + 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). @@ -153,22 +152,21 @@ 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) -> None: - codes = ["DAG-CV-DIM_MISSING", "DAG-TR-EARLY_ERROR", "DAG-CV-DIM_MISSING"] - assert validate(codes) == ["DAG-CV-DIM_MISSING", "DAG-TR-EARLY_ERROR"] + 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) -> None: - assert ( - max_severity(["DAG-CV-DIM_MISSING", "DAG-HG01-POLICY_MISSING"]) - is Severity.CRITICAL - ) + 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) -> 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-BELOW_PROJECTION") is True - assert resolvable("DAG-HG02-EVIDENCE_INVALIDATED") is True - assert resolvable("DAG-HG01-POLICY_MISSING") is False + 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"): diff --git a/tests/test_assurance/test_verdict.py b/tests/test_assurance/test_verdict.py index 47390324..d4de7216 100644 --- a/tests/test_assurance/test_verdict.py +++ b/tests/test_assurance/test_verdict.py @@ -8,10 +8,12 @@ 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, @@ -136,15 +138,24 @@ 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) -> None: + 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-DIM_MISSING", - "DAG-TR-EARLY_ERROR", - "DAG-CV-DIM_MISSING", - ] + reason_codes=["DAG-CV-AA", "DAG-TR-BB", "DAG-CV-AA"] ) - assert v.reason_codes == ["DAG-CV-DIM_MISSING", "DAG-TR-EARLY_ERROR"] + 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"): @@ -161,7 +172,7 @@ def test_outcome_is_a_plain_string(self) -> None: ref = GateVerdictRef( verdict_schema_version="1", outcome="REJECT", - reason_codes=["DAG-HG01-POLICY_MISSING"], + reason_codes=["DAG-HG01-CC"], inputs_hash=_HASH, config_version="sha256:c", ) diff --git a/tests/test_governance/test_trace_schema_v3_compat.py b/tests/test_governance/test_trace_schema_v3_compat.py index 8fcda85d..d5a2e303 100644 --- a/tests/test_governance/test_trace_schema_v3_compat.py +++ b/tests/test_governance/test_trace_schema_v3_compat.py @@ -33,7 +33,7 @@ def _ref() -> GateVerdictRef: return GateVerdictRef( verdict_schema_version="1", outcome="REJECT", - reason_codes=["DAG-HG01-POLICY_MISSING"], + reason_codes=["DAG-HG01-CC"], decisive_rule="HG-01", cap_applied=True, inputs_hash="sha256:" + "a" * 64, @@ -87,7 +87,7 @@ def test_round_trip_preserves_the_verdict(self) -> None: 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-POLICY_MISSING"] + assert restored.assurance.reason_codes == ["DAG-HG01-CC"] assert restored.schema_version == "3" def test_public_dict_excludes_internal_fields(self) -> None: