diff --git a/CHANGELOG.md b/CHANGELOG.md
index cab014b..57da68d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,10 +9,83 @@ versions per [PEP 440](https://peps.python.org/pep-0440/) /
_Post-1.0.0 work lands here; legis versions independently from the Weft 1.0 launch on._
-## [1.3.0] — 2026-06-26
-
-Suite-standard dot-dir hygiene, plus the legis-resident halves of six cross-repo
-Weft seam conformance oracles.
+## [1.3.0] — 2026-06-28
+
+Suite-standard dot-dir hygiene and the legis-resident halves of six cross-repo
+Weft seam conformance oracles; three security/federation fixes (two
+governance-honesty hardenings on the posture and protected-cell audit paths, and
+a rebuild of the Warpline advisory-preflight seam onto Warpline's real MCP wire);
+and two new federation read surfaces — `governance_read.v1` (the per-SEI
+governance read legis now **publishes** for Warpline) and a Plainweave
+advisory-preflight **consumer**.
+
+### Security
+
+- **Posture `read_floor()` fails closed on a chain-integrity break
+ (legis-476ab6f125).** The hot routing read now gates on the keyless
+ `verify_integrity()` chain re-hash before returning the floor, so a
+ raw-DB-written/forged ledger tail can no longer silently set the routing floor:
+ an unverifiable chain resolves to `None` → the fail-closed `structured`
+ default, never the tampered floor. A recomputed-keyless-chain forgery remains
+ the conceded raw-file-write residual (README "Known security limitations"),
+ pinned by a characterization test rather than implied-closed.
+- **Protected-cell batches advance the HeadAnchor (legis-0c310712a7).**
+ `ProtectedGate` gains a `transaction()` context manager — a parity mirror of
+ `SignoffGate.transaction()` — that advances the out-of-band HeadAnchor after a
+ batched protected append commits, so a later tail-truncation back to a stale
+ anchor is detectable. Preventive/parity hardening: production wires no anchor
+ and no caller batches protected appends today.
+
+### Changed — Warpline advisory preflight rebuilt onto the real MCP wire
+
+- **`warpline_preflight_get` now speaks Warpline's actual surface
+ (legis-a53d92507d).** The prior client issued `GET /api/impact-radius` against
+ an HTTP server Warpline never served. It is replaced by a stdlib stdio JSON-RPC
+ client (`WarplineMcpClient` + `StdioMcpInvoke`, no new dependency) that consumes
+ Warpline's **extant** `warpline_impact_radius_get` / `warpline_reverify_worklist_get`
+ MCP tools with a `rev_range`, parses the real `warpline.impact_radius.v1` /
+ `warpline.reverify_worklist.v1` envelope, and verifies the
+ `meta.local_only` / `peer_side_effects` invariant (GV-LG-3), failing closed on
+ every fault. Config moves from `WARPLINE_API_URL` to `WARPLINE_MCP_CMD`. The
+ advisory boundary is unchanged: governance verdicts stay byte-identical with or
+ without Warpline, and any failure degrades to a discriminated `unavailable`.
+ This **reverses** the earlier "Warpline must ship a flat HTTP producer" framing
+ — legis conforms to Warpline's frozen envelope (hub SEAM 4 §4A / GV-LG-3);
+ Warpline builds nothing.
+
+### Added — federation read surfaces: `governance_read.v1` (producer) + Plainweave (consumer)
+
+- **`governance_read.v1` — legis publishes a per-SEI governance read for Warpline
+ (PDR-0007).** A new surface on CLI (`legis governance-read `), MCP
+ (`governance_read` tool) and HTTP (`GET /governance/sei/{sei:path}/governance-read`),
+ returning the frozen `governance_read.v1` envelope (contract committed at
+ `contracts/governance_read.v1.schema.json`). It is a pure projection of the
+ forge-proof per-SEI attestation read (`read_sei_attestations`) into a
+ **cleared-only** posture-record shape (`operator_override` → `protected_override`,
+ `signoff_cleared` → `operator_signoff`), so Warpline's
+ `reverify_worklist(include_federation=True)` can enrich its worklist with legis
+ governance facts. Fail-closed throughout: an unverifiable trail resolves to a
+ discriminated `status: "unavailable"` (never a silent `records: []`); a tampered
+ trail fails loud on all three transports (HTTP 500 / MCP `AUDIT_INTEGRITY_FAILURE`
+ / CLI nonzero); `records: []` under `checked` is honest absence of clearance,
+ never "ungoverned" or "unknown SEI" (legis is an SEI consumer, never the
+ authority). Advisory-only — Warpline echoes it as `enrichment.governance` and
+ never gates on it (GV-LG-1). The discriminated-union schema and the CLI
+ chain-integrity guard are both **mutation-proven** against false-greens. This
+ ships the producer half of the obligation the 1.3.0 conformance note flagged as
+ unwired; Warpline's consumer (`LegisClient.governance_for_sei`) is the remaining
+ integration.
+
+- **Plainweave preflight — advisory consumer (PDR-0008).** legis gains a read-only
+ `plainweave_preflight_get` consumer of Plainweave's
+ `weft.plainweave.preflight_facts.v1` producer (its first sibling consumer),
+ mirroring the Warpline advisory-preflight read exactly: injectable
+ `PlainweaveMcpClient` + `StdioMcpInvoke`, every fault fails closed →
+ `unavailable`, GV-LG-3 validated against Plainweave's real `authority_boundary`
+ shape, configured via `PLAINWEAVE_MCP_CMD` (default unconfigured →
+ `unavailable`). Enrich-only; governance verdicts stay byte-identical with or
+ without Plainweave. The conformance oracle drives a constructed golden (live
+ end-to-end capture is a flagged follow-up).
### Added
@@ -42,11 +115,22 @@ Weft seam conformance oracles.
the default suite) plus a skip-clean Layer-2 source recheck: SEI
(loomweave→legis), git-renames (legis→loomweave), signoff-binding
(legis→filigree), loomweave-HMAC-wire (legis→loomweave, live-gated), the
- warpline preflight read (legis consumer), and the per-SEI `attestation_get`
- read (legis producer). Two outstanding peer obligations are recorded rather
- than papered over — warpline ships no flat HTTP producer for the preflight
- shape, and warpline's `LegisClient.governance_for_sei` is unwired — with the
- Layer-2 rechecks armed to fire when each peer lands its half.
+ warpline preflight read (legis consumer — now driving Warpline's real
+ `warpline.impact_radius.v1` / `warpline.reverify_worklist.v1` MCP envelope; see
+ *Changed* above), and the per-SEI `attestation_get` read (legis producer). The
+ peer obligation this note previously flagged as unwired — the per-SEI governance
+ read Warpline needs — is now **shipped on legis's side** as `governance_read.v1`
+ (see *Added* above, with its own frozen-golden oracle); Warpline's consumer
+ (`LegisClient.governance_for_sei`) is the remaining integration half, tracked for
+ the live handshake.
+
+### Fixed
+
+- **`legis doctor` no longer double-prints the `[operator]` audience tag.** Five
+ operator-key check messages baked a literal `[operator]` into the message text
+ while the renderer also appends the audience tag, so the lines read
+ `… [operator] [operator]`. The literals are stripped; the renderer remains the
+ single owner of the tag.
## [1.2.0] — 2026-06-25
diff --git a/contracts/governance_read.v1.schema.json b/contracts/governance_read.v1.schema.json
new file mode 100644
index 0000000..64f254a
--- /dev/null
+++ b/contracts/governance_read.v1.schema.json
@@ -0,0 +1,91 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://weft.dev/contracts/governance_read.v1.schema.json",
+ "title": "legis governance_read.v1",
+ "description": "Per-SEI VERIFIED GOVERNANCE CLEARANCE facts produced by legis (the governance authority) and consumed advisorily by siblings (e.g. warpline reverify enrichment). v1 reports verified clearances ONLY (operator overrides + cleared structured/protected sign-offs). 'records: []' under status 'checked' = no verified clearance for this SEI on legis's verified trail -- NOT 'ungoverned', and NOT 'unknown SEI' (legis is an SEI consumer, never the SEI authority; it cannot distinguish unknown-from-ungoverned). status 'unavailable' = legis could not produce a signature-verifiable answer (no protected gate / verifier / key); consumers MUST treat it as 'unavailable', NEVER as 'absent'. A TAMPERED trail does not reach this envelope at all -- it fails loud as a transport-level integrity error (MCP AUDIT_INTEGRITY_FAILURE / HTTP 5xx / CLI nonzero exit).",
+ "type": "object",
+ "required": ["status", "sei", "records"],
+ "additionalProperties": false,
+ "properties": {
+ "status": {
+ "description": "Envelope state. 'checked' = the verified trail was read (records may be empty = honest absence of clearance). 'unavailable' = could not produce a signature-verifiable answer.",
+ "enum": ["checked", "unavailable"]
+ },
+ "sei": {
+ "description": "The SEI queried, echoed verbatim. Opaque; never parsed.",
+ "type": "string",
+ "minLength": 1
+ },
+ "records": {
+ "description": "Verified governance clearance records keyed on this SEI. Empty under 'checked' = honest absence. Always [] under 'unavailable'.",
+ "type": "array",
+ "items": { "$ref": "#/$defs/clearance_record" }
+ },
+ "unavailable": {
+ "description": "Present ONLY under status 'unavailable' (and required there): why legis could not produce a verifiable answer. Non-empty.",
+ "type": "array",
+ "minItems": 1,
+ "items": {
+ "type": "object",
+ "required": ["reason"],
+ "additionalProperties": false,
+ "properties": { "reason": { "type": "string", "minLength": 1 } }
+ }
+ }
+ },
+ "allOf": [
+ {
+ "if": { "properties": { "status": { "const": "unavailable" } }, "required": ["status"] },
+ "then": {
+ "required": ["unavailable"],
+ "properties": { "records": { "maxItems": 0 } }
+ }
+ },
+ {
+ "if": { "properties": { "status": { "const": "checked" } }, "required": ["status"] },
+ "then": { "not": { "required": ["unavailable"] } }
+ }
+ ],
+ "$defs": {
+ "clearance_record": {
+ "type": "object",
+ "required": ["sei", "disposition", "posture", "authority", "as_of", "reasons", "content_hash"],
+ "additionalProperties": false,
+ "properties": {
+ "sei": {
+ "description": "The SEI this clearance is keyed on (== the SIGNED entity_key.value; identity-stable).",
+ "type": "string",
+ "minLength": 1
+ },
+ "disposition": {
+ "description": "Record-level governance disposition. NOT the envelope 'status', NOT a consumer's 'enrichment.governance'. v1 closed enum = {'cleared'}: every record is a verified human clearance. (Future dispositions arrive in governance_read.v2 -- v1 is never mutated.)",
+ "enum": ["cleared"]
+ },
+ "posture": {
+ "description": "The PROVABLE clearance mechanism. legis does NOT claim the enforcement cell for a sign-off (it cannot prove structured-vs-protected for a cleared sign-off) -- it reports the mechanism it can prove. 'protected_override' = a protected operator-override verdict; 'operator_signoff' = an operator-cleared sign-off.",
+ "enum": ["protected_override", "operator_signoff"]
+ },
+ "authority": {
+ "description": "Clearing-authority class. v1: both clearance kinds are operator-cleared.",
+ "enum": ["operator"]
+ },
+ "as_of": {
+ "description": "Timestamp from the SIGNED recorded_at of the clearance record (RFC3339 UTC; '+00:00' and 'Z' both valid). A clearance whose recorded_at is absent or non-RFC3339 is OMITTED by the producer (asymmetric-error rule) -- it never ships as null.",
+ "type": "string",
+ "format": "date-time"
+ },
+ "reasons": {
+ "description": "Closed-vocab codes naming the clearance kind (what happened). Never free prose. Distinct axis from 'posture' (the provable mechanism / how); v1 correlates them 1:1 by construction, but v2 may carry additional reason codes without changing posture.",
+ "type": "array",
+ "minItems": 1,
+ "items": { "enum": ["operator_override", "signoff_cleared"] }
+ },
+ "content_hash": {
+ "description": "The SIGNED Loomweave content hash binding the clearance to the governed content (non-empty).",
+ "type": "string",
+ "minLength": 1
+ }
+ }
+ }
+ }
+}
diff --git a/docs/contracts/warpline-governance-read.v1-prompt.md b/docs/contracts/warpline-governance-read.v1-prompt.md
new file mode 100644
index 0000000..5742a0f
--- /dev/null
+++ b/docs/contracts/warpline-governance-read.v1-prompt.md
@@ -0,0 +1,169 @@
+# Prompt for WARPLINE — wire `LegisGovernanceClient` to legis's `governance_read.v1`
+
+> **Canonical contract:** `contracts/governance_read.v1.schema.json` in the legis repo. **Mirror that
+> file** — it is the source of truth. The copy embedded below is an abridged convenience mirror: every
+> *validating* keyword (type/enum/minLength/format/allOf/if-then) is identical to the canonical file
+> and legis CI asserts that equality; only the human `description` annotations are dropped here (they
+> live in the canonical file).
+
+> **⚠ Contract hardened since the first hand-off (backward-compatible).** The schema now enforces the
+> discriminated union (status `unavailable` ⇒ a non-empty `unavailable` reason array + `records: []`;
+> status `checked` ⇒ no `unavailable` key). This only *tightens* what legis emits — legis never
+> produced the now-rejected shapes — so a consumer that already built against the looser draft is not
+> broken. Re-mirror the schema below for your own validation.
+
+You asked legis to expose a per-SEI governance read so `reverify_worklist(include_federation=True)`
+can enrich its worklist with legis governance facts. Legis owns this contract (`governance_read.v1`)
+because legis is the governance authority; you consume it advisorily. Build your
+`LegisGovernanceClient` against the shape below.
+
+**Before you finalize the consumer, confirm the v1 SCOPE (one section below) matches what your
+`enrichment.governance` displays.** A scope mismatch is the one thing a parallel build hides until
+both sides are done — settle it first. Everything else here is final.
+
+## Trust boundary (restated so the contract can't erode it)
+
+- Legis is the **governance authority**; warpline is an **advisory consumer**. You ECHO legis
+ governance as `enrichment.governance: present | absent | unavailable`. You **NEVER gate** the
+ reverify decision on it. `GV-LG-1` (no `governance_verdict` in warpline output) stays asserted.
+- Legis reports **facts it cryptographically verified**, never a verdict for you. Honest absence is
+ a first-class answer; an unanswerable read fails **loud**, never silent-empty.
+
+## v1 SCOPE — confirm it, THEN finalize ⚠️ the coordination point
+
+`governance_read.v1` reports **verified governance CLEARANCES only**, keyed on SEI:
+
+- `operator_override` — a protected-cell operator-override verdict (`OVERRIDDEN_BY_OPERATOR`), signed.
+- `signoff_cleared` — a structured/protected sign-off an operator **cleared**, signed with an
+ integrity-bound request join.
+
+It does **NOT** report in-flight / uncleared governance (open PENDING sign-offs, BLOCKED verdicts,
+Filigree bindings) — a deliberate v1 non-goal.
+
+**The honesty contract for your enrichment:**
+
+- `records: []` (under `status: "checked"`) means **"legis holds no verified governance clearance
+ for this SEI."** NOT "ungoverned," NOT "unknown SEI." An entity under an *open* structured block
+ has no clearance yet → it returns `[]`. So treat `enrichment.governance: absent` as **"no verified
+ clearance,"** not "ungoverned." If your display would read `absent` as "ungoverned," relabel the
+ key (e.g. `governance_clearance: present|absent`) or tell legis you need in-flight context — that's
+ a **pre-build scope bump** (cheap now) or a `governance_read.v2`, never a mutation of v1. A v2 that
+ ADDS dispositions/kinds leaves every v1 cleared record valid, so v1=cleared-only is a safe subset.
+- **Legis cannot distinguish "unknown SEI" from "known SEI, no clearance"** (it is an SEI consumer,
+ never the authority). `[]` deliberately conflates them. "Does this SEI exist" is a Loomweave query.
+
+→ **Confirm: does cleared-only enrichment match your `enrichment.governance` semantics? Reply before
+finalizing the consumer if you need more than verified clearances.**
+
+## The contract — `governance_read.v1.schema.json` (structural mirror; validating keywords identical, descriptions in the canonical file)
+
+```json
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://weft.dev/contracts/governance_read.v1.schema.json",
+ "title": "legis governance_read.v1",
+ "type": "object",
+ "required": ["status", "sei", "records"],
+ "additionalProperties": false,
+ "properties": {
+ "status": { "enum": ["checked", "unavailable"] },
+ "sei": { "type": "string", "minLength": 1 },
+ "records": { "type": "array", "items": { "$ref": "#/$defs/clearance_record" } },
+ "unavailable": {
+ "type": "array", "minItems": 1,
+ "items": { "type": "object", "required": ["reason"], "additionalProperties": false,
+ "properties": { "reason": { "type": "string", "minLength": 1 } } }
+ }
+ },
+ "allOf": [
+ { "if": { "properties": { "status": { "const": "unavailable" } }, "required": ["status"] },
+ "then": { "required": ["unavailable"], "properties": { "records": { "maxItems": 0 } } } },
+ { "if": { "properties": { "status": { "const": "checked" } }, "required": ["status"] },
+ "then": { "not": { "required": ["unavailable"] } } }
+ ],
+ "$defs": {
+ "clearance_record": {
+ "type": "object",
+ "required": ["sei", "disposition", "posture", "authority", "as_of", "reasons", "content_hash"],
+ "additionalProperties": false,
+ "properties": {
+ "sei": { "type": "string", "minLength": 1 },
+ "disposition": { "enum": ["cleared"] },
+ "posture": { "enum": ["protected_override", "operator_signoff"] },
+ "authority": { "enum": ["operator"] },
+ "as_of": { "type": "string", "format": "date-time" },
+ "reasons": { "type": "array", "minItems": 1,
+ "items": { "enum": ["operator_override", "signoff_cleared"] } },
+ "content_hash": { "type": "string", "minLength": 1 }
+ }
+ }
+ }
+}
+```
+
+Field meanings: `disposition` = record-level (NOT the envelope `status`, NOT your
+`enrichment.governance`); v1 = `{cleared}`. `posture` = the **provable** clearance mechanism
+(`protected_override` | `operator_signoff` — legis won't claim structured-vs-protected for a
+sign-off). `authority` = `operator`. `as_of` = the signed `recorded_at` (RFC3339 UTC; a clearance
+whose `recorded_at` is missing/non-RFC3339 is OMITTED by legis, never shipped as null). `reasons` =
+closed-vocab kind codes. `content_hash` = the signed Loomweave content hash (non-empty).
+
+### Literal samples (validate against the schema)
+
+```json
+{ "status": "checked", "sei": "loomweave:eid:7Q3f...c1", "records": [
+ { "sei": "loomweave:eid:7Q3f...c1", "disposition": "cleared", "posture": "protected_override",
+ "authority": "operator", "as_of": "2026-06-27T14:02:11Z", "reasons": ["operator_override"],
+ "content_hash": "b3:9f2c...e7" },
+ { "sei": "loomweave:eid:7Q3f...c1", "disposition": "cleared", "posture": "operator_signoff",
+ "authority": "operator", "as_of": "2026-06-26T09:41:55Z", "reasons": ["signoff_cleared"],
+ "content_hash": "b3:5a10...92" } ] }
+```
+```json
+{ "status": "checked", "sei": "loomweave:eid:unknown...", "records": [] }
+```
+```json
+{ "status": "unavailable", "sei": "loomweave:eid:7Q3f...c1", "records": [],
+ "unavailable": [{ "reason": "trail not signature-verifiable (no protected gate / verifier)" }] }
+```
+
+## The three transports legis exposes (same `governance_read.v1` envelope on each)
+
+- **MCP tool** `governance_read`, args `{ "sei": "" }` — surfaces the envelope as its result
+ (with a matching `outputSchema`); a **tampered** trail returns the MCP **error** envelope
+ (`AUDIT_INTEGRITY_FAILURE`), not a result. *(You're already an MCP client of legis — likely your path.)*
+- **HTTP** `GET /governance/sei/{sei}/governance-read` — body is the envelope; tampered trail = 5xx.
+ (SEI is captured as a full path segment; URL-encode a SEI containing `/`.)
+- **CLI** `legis governance-read ` — stdout the envelope JSON; tampered trail / missing store =
+ loud (nonzero exit / `status:"unavailable"` for a genuinely absent store, never a silent `checked/[]`).
+
+## Your `LegisGovernanceClient` — the mapping (satisfies `LegisClient` Protocol)
+
+```python
+class LegisGovernanceClient: # satisfies warpline's LegisClient Protocol
+ def governance_for_sei(self, sei: str) -> list[dict]:
+ try:
+ resp = self._invoke_legis_governance_read(sei) # MCP tool / HTTP GET / CLI
+ except (TransportError, LegisIntegrityError):
+ raise LegisGovernanceUnavailable(sei) # tampered/transport -> unavailable
+ if resp["status"] == "unavailable":
+ raise LegisGovernanceUnavailable(sei, resp.get("unavailable")) # -> unavailable
+ return resp["records"] # [] -> absent (= "no verified clearance") ; [...] -> present
+```
+
+Then in `_h_reverify` (`warpline mcp.py:441`, the single `legis_client=None` site): pass a
+`LegisGovernanceClient()`. Expected: the legis federation member flips `weft_reason: disabled` →
+`clean` with records under `entities[].governance`; `enrichment.governance` flips `unavailable` →
+`present` for cleared entities, `absent` otherwise.
+
+## Acceptance (your side)
+
+1. `LegisGovernanceClient.governance_for_sei` satisfies the `LegisClient` Protocol; in
+ `reverify_worklist(include_federation=True)` the legis member is `clean` (not `disabled`) with
+ records for a known-cleared entity.
+2. Reachable SEI, no clearance → `[]` → `absent`; unavailable/tampered legis → **raises** →
+ `unavailable`. Never the reverse (`GV-LG`/`GOV-2` discipline).
+3. A test asserts `enrichment.governance` does NOT affect the reverify decision (advisory boundary;
+ `GV-LG-1` `governance_verdict` stays absent).
+4. A captured real legis response validates against `governance_read.v1.schema.json` (the tightened
+ discriminated-union version above).
diff --git a/docs/plans/2026-06-26-posture-read-floor-verify-integrity.md b/docs/plans/2026-06-26-posture-read-floor-verify-integrity.md
new file mode 100644
index 0000000..8f8fcc3
--- /dev/null
+++ b/docs/plans/2026-06-26-posture-read-floor-verify-integrity.md
@@ -0,0 +1,403 @@
+# Posture read_floor Fail-Closed Integrity Gate — Implementation Plan
+
+> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
+
+**Goal:** Make `PostureLedger.read_floor()` fail closed when the ledger cannot prove its own chain integrity, so a raw-DB-written/forged tail record can no longer silently set the routing floor (legis-476ab6f125; PRD-0005 criterion 1).
+
+**Architecture:** `read_floor()` gains a `verify_integrity()` gate (the existing keyless O(N) chain re-hash on `AuditStore`) before its descending floor scan; a failed verification returns `None`, which every caller already maps to the fail-closed `structured` default. The keyless hot read proves **integrity + tail-kind** — NOT operator authorization. Cryptographic `operator_sig` verification under the epoch key stays the operator-side `doctor` check, but note its true scope: `doctor` verifies `operator_sig` only in `_transition_acknowledges` (`doctor.py:649`), reached only via `check_posture_key_reset` (`doctor.py:677`, which early-returns "no key-epoch reset" at `:707` when there is none) **when there is a `KEY_RESET` to acknowledge**. A file-write attacker who *recomputes* the keyless chain on a forged floor-lowering `TRANSITION` of a **non-rekeyed** ledger is therefore caught by **neither** this hot read **nor** `doctor` today — it is a pure conceded raw-file-write residual (`README.md:137`), pinned by an explicit characterization test, not implied-closed.
+
+**Tech Stack:** Python 3.12, SQLAlchemy Core over SQLite, `uv`, pytest. No new dependencies.
+
+**Prerequisites:**
+- Work on a feature branch / worktree, NOT `main` (e.g. `git switch -c fix/posture-read-floor-verify-integrity`). Per the authority grant, the merge to main + any publish is owner-gated; this plan ends at a green branch + an accepted finding.
+- `uv sync --dev` already run; `.venv` present.
+- Read context (already grounded): `src/legis/posture/ledger.py:92` (`read_floor`), `src/legis/store/audit_store.py:362` (`verify_integrity`, keyless), `src/legis/doctor.py:649` (`_transition_acknowledges`) / `:677` (`check_posture_key_reset` — KEY_RESET-ack path only, early-returns at `:707` with no reset), `tests/posture/test_security_honesty.py` (fixtures + the suite the two new tests land in), `tests/posture/test_ledger.py:128` (`test_read_floor_uses_tail_read` — the one existing test the fix retires; see Task 1 Step 5), `tests/store/test_audit_store.py:22,78` (the canonical `sqlite3.connect` raw-DB-write pattern the new tests reuse), `README.md:137` (the conceded residual to cite).
+
+**Scope fence (PRD-0005 non-goals):** Do NOT add operator-key verification to the hot read, do NOT touch `src/legis/store/audit_store.py` or `canonical.py` (the cross-tool HMAC contract), do NOT re-architect the posture subsystem. The change touches exactly: `read_floor()` + the module docstring in `ledger.py`; **two new tests** in `test_security_honesty.py`; and **one rewritten test** in `test_ledger.py` (`test_read_floor_uses_tail_read`, whose "must not call read_all" premise the integrity gate deliberately retires).
+
+**Scope acknowledgment (tracked follow-ups, NOT this PR):** three sibling reads call `read_all()` with no `verify_integrity()` gate — `epoch_reset_unacknowledged` (`ledger.py:120`), `current_epoch_fingerprint` (`ledger.py:151`), `session_opened_recorded` (`ledger.py:299`). `read_floor` is the P0 (it alone feeds the routing chokepoint `floored_registry` with no downstream keyed check) and is closed here. The siblings that feed `set_floor` are defended downstream by the operator key; but `epoch_reset_unacknowledged` feeds the agent-facing `posture_get` MCP tool (`mcp.py:2550`) with **no** keyed backstop — file a follow-up for it. Do not let these remain silently scoped out (the plan's own honesty discipline: make residuals explicit, never implied-closed).
+
+---
+
+### Task 1: read_floor fails closed on a chain-integrity break
+
+**Files:**
+- Modify: `src/legis/posture/ledger.py` — `read_floor` (lines 92–118: add the gate + honest docstring) **and** the module docstring (lines 13–18, which currently claims the floor is found "never the O(N) `read_all` loop" — false post-fix).
+- Test (new): `tests/posture/test_security_honesty.py` (add one test, reuse existing fixtures)
+- Test (rewrite): `tests/posture/test_ledger.py` — `test_read_floor_uses_tail_read` (Step 5; its premise is retired by the gate)
+
+**Step 1: Write the failing test**
+
+Add to `tests/posture/test_security_honesty.py`. Module imports needed at top (add if absent): `import json`, `import sqlite3`, and `from legis.posture.ledger import _sqlite_file` (the production URL→path helper, so the raw write hits the exact file the store engine uses). Reuses the file's existing `_genesis`, `_MemSigner`, `_open_recorded_session`, `set_floor`, `FixedClock`, `KIND_TRANSITION`, `mint_key`.
+
+```python
+def test_read_floor_fails_closed_on_integrity_break(tmp_path):
+ """A raw-DB tail record that lowers the floor but breaks the keyless hash
+ chain must NOT be trusted: read_floor() fails closed (returns None ->
+ structured), never the forged floor. (legis-476ab6f125; PRD-0005 crit 1.)
+
+ Uses the canonical raw-file-write attacker model (sqlite3.connect, as in
+ tests/store/test_audit_store.py:22,78), not the ORM. In-place-edit / reorder
+ / seq-gap tamper is already pinned for the same gate at
+ tests/store/test_audit_store.py:78 and :246; this test exercises the
+ tail-append vector through read_floor specifically.
+ """
+ key_hex = mint_key()
+ key_bytes = bytes.fromhex(key_hex)
+ ledger, _ = _genesis(tmp_path, key_hex=key_hex) # GENESIS @ chill
+
+ # Elevate to protected via a signed transition so a downgrade is visible.
+ _open_recorded_session(ledger, signer=_MemSigner(key_bytes))
+ set_floor(
+ "protected", ledger=ledger, signer=_MemSigner(key_bytes),
+ agent_id="op", rationale="tighten", clock=FixedClock("t1"),
+ )
+ assert ledger.read_floor() == "protected"
+
+ # Simulate a raw-file-write attacker: append a tail row claiming
+ # floor="chill" with a BROKEN chain (garbage hashes). INSERT is allowed:
+ # the append-only triggers block only UPDATE/DELETE.
+ head_seq, _ = ledger.store.get_latest_sequence_and_hash()
+ forged = {
+ "kind": KIND_TRANSITION, "floor": "chill", "operator_sig": None,
+ "key_fingerprint": "x", "agent_id": "attacker", "recorded_at": "t9",
+ "rationale": "forged", "session_id": None,
+ }
+ conn = sqlite3.connect(str(_sqlite_file(ledger._url)))
+ try:
+ conn.execute(
+ "INSERT INTO audit_log (seq, payload, content_hash, prev_hash, "
+ "chain_hash) VALUES (:seq, :payload, :ch, :ph, :xh)",
+ {
+ "seq": head_seq + 1, "payload": json.dumps(forged),
+ "ch": "0" * 64, # does NOT match payload -> verify_integrity fails
+ "ph": "0" * 64, "xh": "0" * 64,
+ },
+ )
+ conn.commit()
+ finally:
+ conn.close()
+
+ # Pre-fix: the descending scan returns the forged "chill". Post-fix: the
+ # broken chain fails verify_integrity, so read_floor fails closed.
+ assert ledger.read_floor() is None
+```
+
+**Why this test:** It pins the finding's load-bearing defense — an integrity-breaking forged tail (the realistic naive raw writer) must fail closed. It asserts the *integrity* path (the real fix), not a presence check.
+
+**Step 2: Run test to verify it fails**
+
+Run: `uv run pytest tests/posture/test_security_honesty.py::test_read_floor_fails_closed_on_integrity_break -v`
+
+Expected output (RED — the bug):
+```
+FAILED ... assert 'chill' is None
+```
+(`read_floor()` currently returns the forged `"chill"`; the assertion `is None` fails — the right reason.)
+
+**Step 3: Write minimal implementation**
+
+In `src/legis/posture/ledger.py`, edit `read_floor` (currently lines 92–118): replace the docstring and insert the `verify_integrity()` gate immediately after the absent-file early return, before `_assert_no_batch_in_progress`.
+
+```python
+ def read_floor(self) -> str | None:
+ """The current floor (latest authoritative floor record), or ``None``.
+
+ Fail-closed: the floor sets routing, so the ledger must first PROVE its
+ own integrity. ``verify_integrity()`` (an O(N) keyless chain re-hash)
+ gates the read; a chain that does not verify — a raw-write in-place edit,
+ reorder, or seq gap — yields ``None`` (callers map ``None`` -> the
+ fail-closed ``structured`` default), never the tampered floor. A missing
+ DB file or an empty store also report ``None`` (``verify_integrity`` is
+ True on an empty store; the table-absence check returns ``None`` below).
+
+ The O(N) integrity walk runs on EVERY resolution (the floor is never
+ cached, design D2) and is deliberate: it is bounded by operator-action
+ volume (genesis/transition/rekey are operator-gated; session-open churn
+ is bounded by TTL expiry + human-in-the-loop enabling), immaterial at
+ posture-ledger scale on local SQLite, and an *unverified* hot read was
+ the whole bug. The two failure modes are both fail-closed but
+ asymmetric: a DETECTED tamper (a clean walk that does not verify)
+ resolves to ``None`` -> ``structured``; an I/O fault (locked/corrupt DB
+ raising ``OperationalError``) propagates as an exception and aborts the
+ read — neither is a pass. Do NOT wrap the gate in a try/except that
+ downgrades that raise to a permissive default.
+
+ SCOPE (honesty): the chain is keyless SHA, so this proves integrity +
+ tail-kind but NOT operator authorization. A file-write attacker who
+ *recomputes* the keyless chain on a forged floor-lowering ``TRANSITION``
+ passes this gate; on a non-rekeyed ledger that forgery is caught by
+ NEITHER this keyless hot read NOR ``doctor`` today — it is a PURE
+ conceded raw-file-write residual (README "Known security limitations",
+ README.md:137). ``doctor``'s keyed ``operator_sig`` verification
+ (``_transition_acknowledges``, doctor.py:649) covers ONLY the
+ ``KEY_RESET``-acknowledgment path (D6), not a ``TRANSITION`` on a ledger
+ with no reset to acknowledge. A general per-transition ``operator_sig``
+ audit in ``doctor`` would close the residual operator-side, but that is
+ separate follow-up, not this change. See
+ ``test_read_floor_fails_closed_on_integrity_break`` and
+ ``test_read_floor_recomputed_chain_forgery_is_conceded_residual``.
+ """
+ path = _sqlite_file(self._url)
+ if path is not None and not path.exists():
+ return None
+ # Fail closed if the chain cannot prove integrity: a tampered/forged
+ # ledger must not be trusted to set the routing floor. verify_integrity()
+ # returns True on an empty store, so an absent/empty ledger still reads
+ # as None via the table-absence check below, not a spurious failure.
+ if not self.store.verify_integrity():
+ return None
+ self.store._assert_no_batch_in_progress("read_floor")
+ with self.store._engine.begin() as conn:
+ if not self.store._has_log_table(conn):
+ return None
+ rows = conn.execute(
+ select(self.store._log.c.payload)
+ .order_by(self.store._log.c.seq.desc())
+ )
+ for row in rows:
+ payload = json.loads(row.payload)
+ kind = payload.get("kind")
+ floor = payload.get("floor")
+ if kind in self._FLOOR_RECORD_KINDS and floor is not None:
+ return floor
+ return None
+```
+
+Then update the **module docstring** (`ledger.py:13–18`) so it no longer claims the floor is found "never the O(N) `read_all` loop" — that invariant is retired by the gate. Replace that bullet with:
+
+```
+ * The current floor is the latest authoritative floor record's ``floor`` field
+ (``GENESIS`` / ``TRANSITION`` / ``KEY_RESET``). An O(N) keyless
+ ``verify_integrity`` walk GATES the read (fail-closed: a chain that does not
+ verify yields ``None`` -> ``structured``); the floor itself is then found by
+ one descending payload scan from the tail, never a repeated point-read loop
+ over metadata. Metadata records such as ``OPERATOR_SESSION_OPENED`` must not
+ lower the effective floor, even if they carry a stale ``floor`` field.
+```
+
+**Why minimal:** Only the `verify_integrity()` gate + the two honesty docstrings are added; the existing descending scan is untouched. No operator-key handling (deliberately out of the keyless hot read — that is `doctor`'s job and a PRD non-goal). `verify_integrity()` opens/closes its own connection (NullPool), so the subsequent `_engine.begin()` scan is safe; the double O(N) pass is immaterial on a small posture ledger and avoids touching the sensitive `audit_store` HMAC layer. **This consciously reverses a prior architecture decision:** the 2026-06-16 posture-ratchet spec (`docs/superpowers/specs/2026-06-16-legis-posture-ratchet-plan.md:509`) optimized `read_floor()` to stay *off* `read_all()` on the per-request hot path; integrity-before-trust now takes precedence over that O(1) optimization. The tradeoff is intentional and bounded (see the docstring), not an oversight — security before hot-path thrift, justified by the small operator-gated ledger. (The existing `_assert_no_batch_in_progress("read_floor")` is now belt-and-suspenders — `verify_integrity()` runs the same guard first — but is harmless and documents intent; leave it.)
+
+**Step 4: Run test to verify it passes**
+
+Run: `uv run pytest tests/posture/test_security_honesty.py::test_read_floor_fails_closed_on_integrity_break -v`
+
+Expected output:
+```
+PASSED
+```
+
+**Step 5: Rewrite the one existing test the gate retires**
+
+`tests/posture/test_ledger.py:128–136` (`test_read_floor_uses_tail_read`) monkeypatches `ledger.store.read_all` to RAISE `AssertionError("read_floor must not call read_all (hot path)")`, then asserts `read_floor() == "chill"` on a valid genesis chain. Post-fix, `read_floor()` legitimately calls `read_all` via `verify_integrity()`; `verify_integrity` catches only `(JSONDecodeError, TypeError, ValueError)` (`audit_store.py:373`), so the `AssertionError` propagates and the test **errors**. Its "must not call read_all" premise is exactly the property the integrity-before-trust gate deliberately supersedes — rewrite it to assert the new contract:
+
+```python
+def test_read_floor_verifies_integrity_before_returning_floor(tmp_path):
+ """read_floor() gates on verify_integrity() before the tail scan: on a valid
+ chain the gate passes and the floor is returned. Supersedes the old
+ 'read_floor must not call read_all' guard — the integrity-before-trust gate
+ DELIBERATELY calls read_all via verify_integrity; that property is RETIRED,
+ not regressed. (legis-476ab6f125.)
+ """
+ ledger = PostureLedger(_url(tmp_path), initialize=True)
+ ledger.genesis(key_fingerprint="ab" * 32, agent_id="installer", recorded_at="t0")
+ assert ledger.store.verify_integrity() is True
+ assert ledger.read_floor() == "chill"
+```
+
+(The `read_by_seq`-patching sibling `test_read_floor_does_not_point_read_each_metadata_tail` at `:139` is UNAFFECTED — `verify_integrity` does not call `read_by_seq`.)
+
+> **TWO TRAPS — DO NOT:** (a) do NOT flip this test to `is None`: a valid genesis chain MUST read `"chill"`, and flipping it wouldn't pass anyway (the old `_boom` raised before any assertion). (b) do NOT "fix" a red `test_read_floor_uses_tail_read` by weakening or reverting the `verify_integrity()` gate to restore the no-`read_all` property — that reintroduces the exact false-green this finding closes. The rename + rewrite IS the fix.
+
+Run: `uv run pytest tests/posture/test_ledger.py::test_read_floor_verifies_integrity_before_returning_floor -v` → `PASSED`.
+
+**Step 6: Commit**
+
+```bash
+git add src/legis/posture/ledger.py tests/posture/test_security_honesty.py tests/posture/test_ledger.py
+git commit -m "fix(posture): read_floor fails closed on a chain-integrity break
+
+read_floor() now gates on verify_integrity() before returning the floor, so
+a raw-DB-written/forged tail record that breaks the keyless hash chain can no
+longer silently set the routing floor (it maps to the fail-closed structured
+default). Cryptographic operator_sig verification stays the operator-side
+doctor check (KEY_RESET-acknowledgment path only); a recomputed-chain forgery
+remains the conceded raw-file-write residual (README.md:137).
+
+Retires test_read_floor_uses_tail_read's 'must not call read_all' premise (the
+integrity gate supersedes it) and corrects the now-false module docstring.
+
+Closes the load-bearing half of legis-476ab6f125 (PRD-0005 criterion 1).
+
+Co-Authored-By: Claude Opus 4.8 (1M context) "
+```
+
+**Definition of Done:**
+- [ ] New test written and fails for the right reason (returns "chill" pre-fix)
+- [ ] `read_floor` gates on `verify_integrity()` and fails closed to `None`
+- [ ] `read_floor` docstring + module docstring (lines 13–18) updated to state the new O(N) cost, the fail-closed asymmetry, and the keyless-read honesty scope (no false `doctor` backstop claim)
+- [ ] `test_read_floor_uses_tail_read` rewritten to the new contract (NOT flipped to `is None`, gate NOT weakened)
+- [ ] New test passes post-fix; rewritten test passes
+- [ ] Committed
+
+---
+
+### Task 2: document the recomputed-chain forgery residual (characterization test)
+
+**Files:**
+- Test (new): `tests/posture/test_security_honesty.py` (add one test — no production change)
+
+**Step 1: Write the documentation test**
+
+This test PASSES on the Task-1 code (it asserts a *known limit*, not a new behavior). It exists so the residual is visible and executable in the suite, never implied-closed — the cardinal-sin guard for an honesty tool (a green test an attacker walks around). Reuses the Task-1 imports (`json`, `sqlite3`, `_sqlite_file`) plus the canonical-chain primitives.
+
+```python
+def test_read_floor_recomputed_chain_forgery_is_conceded_residual(tmp_path):
+ """DOCUMENTS the limit so it is visible in the suite, not implied-closed.
+
+ A file-write attacker who *recomputes* the KEYLESS chain (valid content_hash,
+ prev_hash, chain_hash) on a forged floor-lowering TRANSITION passes
+ verify_integrity() — the keyless hot read CANNOT detect this. On a
+ non-rekeyed ledger it is caught by NEITHER this hot read NOR `doctor`:
+ doctor's operator_sig verification (_transition_acknowledges) runs ONLY on
+ the KEY_RESET-acknowledgment path, and there is no KEY_RESET here. It is the
+ conceded raw-file-write residual (README "Known security limitations",
+ README.md:137). A general per-transition operator_sig audit in doctor would
+ close it operator-side (separate follow-up). If a future change makes
+ read_floor reject this, that is a STRENGTHENING: update this test
+ deliberately — do not let it silently flip for the wrong reason.
+ """
+ from legis.canonical import canonical_json, content_hash
+ # Recompute the chain with the PRODUCTION primitive so the residual is not
+ # undermined by a divergent re-implementation (precedent: test_audit_store.py:10).
+ from legis.store.audit_store import _chain
+
+ key_hex = mint_key()
+ key_bytes = bytes.fromhex(key_hex)
+ ledger, _ = _genesis(tmp_path, key_hex=key_hex)
+ _open_recorded_session(ledger, signer=_MemSigner(key_bytes))
+ set_floor(
+ "protected", ledger=ledger, signer=_MemSigner(key_bytes),
+ agent_id="op", rationale="tighten", clock=FixedClock("t1"),
+ )
+ assert ledger.read_floor() == "protected"
+
+ # Forge a floor-lowering TRANSITION with a CORRECTLY recomputed keyless chain.
+ head_seq, head_chain = ledger.store.get_latest_sequence_and_hash()
+ forged = {
+ "kind": KIND_TRANSITION, "floor": "chill", "operator_sig": "junk",
+ "key_fingerprint": "x", "agent_id": "attacker", "recorded_at": "t9",
+ "rationale": "forged", "session_id": None,
+ }
+ c_hash = content_hash(forged) # correct keyless content hash
+ chain_hash = _chain(head_chain, c_hash) # correct keyless chain link
+ conn = sqlite3.connect(str(_sqlite_file(ledger._url)))
+ try:
+ conn.execute(
+ "INSERT INTO audit_log (seq, payload, content_hash, prev_hash, "
+ "chain_hash) VALUES (:seq, :payload, :ch, :ph, :xh)",
+ {
+ "seq": head_seq + 1, "payload": canonical_json(forged),
+ "ch": c_hash, "ph": head_chain, "xh": chain_hash,
+ },
+ )
+ conn.commit()
+ finally:
+ conn.close()
+
+ # Integrity holds (keyless chain valid) -> the keyless read is fooled.
+ # This asserts the DOCUMENTED RESIDUAL, not a desired guarantee.
+ assert ledger.store.verify_integrity() is True
+ assert ledger.read_floor() == "chill"
+```
+
+**Why this test:** Without it, a reader could believe Task 1 fully "authenticated the tail". It makes the conceded residual an executable fact tied to `README.md:137`, and a tripwire: a future change that closes the residual must update this test on purpose.
+
+**Step 2: Run test to verify it passes**
+
+Run: `uv run pytest "tests/posture/test_security_honesty.py::test_read_floor_recomputed_chain_forgery_is_conceded_residual" -v`
+
+Expected output:
+```
+PASSED
+```
+
+(If it FAILS because `read_floor()` returned `None`, the keyless chain was not recomputed correctly in the fixture — `payload` must be `canonical_json(forged)` and `content_hash`/`chain_hash` must be the production-primitive values so `verify_integrity` accepts it. Fix the fixture, not the assertion.)
+
+**Step 3: Commit**
+
+```bash
+git add tests/posture/test_security_honesty.py
+git commit -m "test(posture): pin the recomputed-chain forgery as a documented residual
+
+read_floor's keyless integrity gate cannot detect a file-write attacker who
+recomputes the keyless chain; on a non-rekeyed ledger that is caught by neither
+the hot read nor doctor (doctor's operator_sig check is the KEY_RESET-ack path
+only). It is the conceded raw-file-write residual (README.md:137). This
+characterization test makes the limit visible in the suite so it is never
+implied-closed.
+
+Co-Authored-By: Claude Opus 4.8 (1M context) "
+```
+
+**Definition of Done:**
+- [ ] Documentation test added and passing
+- [ ] Docstring/name make clear it asserts a residual, not a guarantee, and name the precise reason doctor does not catch it (no KEY_RESET)
+- [ ] Committed
+
+---
+
+### Task 3: full verification (suite + coverage floor + gates)
+
+**Files:** none (verification only).
+
+**Step 1: Run the full posture suite — confirm no regression**
+
+Run: `uv run pytest tests/posture -v`
+
+Expected: all pass — **after** the Task 1 Step 5 rewrite of `test_read_floor_uses_tail_read`. That is the ONLY existing test the gate disturbs (it monkeypatched `read_all` to forbid it; the gate now legitimately calls it). It is a valid-chain test, not a bug-asserting one, so it is *rewritten to the new contract in Task 1*, never "flipped to expect `None`". No other existing test asserts the bug: the valid-chain tests (`test_rekey_preserves_existing_floor`, `test_tty_session_expiry`, `test_every_signature_carries_session_id`, `test_read_floor_does_not_point_read_each_metadata_tail`) build well-formed chains, so `verify_integrity()` is True and their behavior is unchanged. **If `test_read_floor_uses_tail_read` is still red here, the remedy is the Task 1 rewrite — NOT weakening the `verify_integrity()` gate.**
+
+**Step 2: Run the per-package coverage floor (posture ≥ 93%)**
+
+Run: `uv run pytest tests/posture --cov=legis.posture --cov-report=term-missing`
+then: `uv run python scripts/check_coverage_floors.py`
+
+Expected: `src/legis/posture/` ≥ 93.0% (both branches of the single added conditional are covered — the `False`→`None` path by Task 1's new test, the integrity-holds path by Task 2 and the existing valid-chain tests). No floor breach.
+
+**Step 3: Run the CI-equivalent gates**
+
+Run, expecting all green:
+```bash
+uv run pytest --cov=legis --cov-fail-under=88
+uv run mypy src/legis
+uv run ruff check src
+uv run legis governance-gate
+```
+
+Expected output: pytest passes with total coverage ≥ 88; mypy clean; ruff clean (E4/E7/E9/F); governance-gate passes. (`read_floor` returns `str | None` unchanged, so mypy is unaffected.)
+
+**Definition of Done:**
+- [ ] `tests/posture` green; the one disturbed test rewritten (Task 1 Step 5), the fix NOT weakened
+- [ ] Posture per-package coverage ≥ 93%; global ≥ 88
+- [ ] mypy + ruff + governance-gate green
+- [ ] Branch ready for review (NOT merged — owner-gated)
+
+---
+
+## After execution — acceptance + closeout (product-owner, post-merge)
+
+Once the branch is green and merged (owner-gated), this finding is **accepted** against PRD-0005 criterion 1:
+- Close `legis-476ab6f125` in the tracker with the close commit (walk proposed→…→done; `commit=main@`).
+- The north-star (`metrics.md`: open governance-honesty defects) drops 3 → 2.
+- File the **scope-acknowledgment follow-ups** (rank 4 of the review): a finding for `epoch_reset_unacknowledged` (feeds the agent-facing `posture_get` MCP read with no keyed backstop), and note `current_epoch_fingerprint` / `session_opened_recorded` as the same class (defended downstream by the operator key, lower priority).
+- Note a **diagnostic-honesty follow-on** (rank 8): post-fix, `doctor.check_posture_ledger` (`doctor.py:578`, warn at `:603`) maps `read_floor()==None` to a "re-run `legis install`" warn — but `None` now also means "chain verification failed", so the warn misdirects an operator (who should investigate storage / restore from backup; `check_posture_chain` already surfaces the real error). Not a false-green (it is a warn), but worth a cleanup so the two `None` causes are distinguished.
+- Then plan the next finding (`legis-0c310712a7`, the un-anchored protected batch) — note its overlap risk with PRD-0005's "shared store-transaction wrapper" assumption.
+
+## Validate before execution (recommended — security-critical)
+
+This is a governance-honesty security fix on a 93%-floor package. **RECOMMENDED:** re-run `/review-plan docs/plans/2026-06-26-posture-read-floor-verify-integrity.md` for reality/architecture/quality/systems verdicts before execution (a prior review's synthesis does not carry forward).
+
+**Review history:** a 7-agent + synthesizer review (2026-06-26) returned **CHANGES_REQUESTED** on the prior draft and is fully addressed here:
+- **Blocker 1** (rank 1, HIGH): the prior draft missed that `test_ledger.py::test_read_floor_uses_tail_read` breaks (errors) under the gate and gave a misdirecting "expect `None`" remedy that could nudge an implementer to weaken the gate. → Now `test_ledger.py` is in scope, Task 1 Step 5 rewrites the test to the new contract with explicit "do not weaken the gate / do not flip to `is None`" guardrails, and Task 3 Step 1 is corrected.
+- **Blocker 2** (rank 2, HIGH): the prior docstrings claimed `doctor`'s `operator_sig` check backstops the recomputed-chain residual — false (`doctor` checks `operator_sig` only on the KEY_RESET-acknowledgment path; Task 2's forged TRANSITION has no KEY_RESET). → Architecture note, `read_floor` docstring, and the Task 2 test docstring now state the residual is caught by neither hot read nor doctor; doctor locators corrected to the verified lines (`_transition_acknowledges` `:649`, `check_posture_key_reset` `:677`, early "no key-epoch reset" return `:707`) after a round-2 grep caught an offset-shifted earlier read.
+- Folded in: stale module docstring (rank 3), sibling-read scope acknowledgment + `epoch_reset_unacknowledged` follow-up (rank 4), faithful `sqlite3` raw-write pattern replacing ORM internals (rank 5), in-place-edit/seq-gap coverage comment (rank 6), deliberate-O(N) docstring bound (rank 7), `doctor` diagnostic follow-on (rank 8), and the fail-closed-asymmetry / belt-and-suspenders notes (rank 9).
diff --git a/docs/plans/2026-06-26-protected-batch-headanchor-transaction.md b/docs/plans/2026-06-26-protected-batch-headanchor-transaction.md
new file mode 100644
index 0000000..ca365d2
--- /dev/null
+++ b/docs/plans/2026-06-26-protected-batch-headanchor-transaction.md
@@ -0,0 +1,327 @@
+# Protected-Gate Transaction + HeadAnchor Advance — Implementation Plan
+
+> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
+
+**Goal:** Give `ProtectedGate` an owned `transaction()` context manager that advances the `HeadAnchor` after the batch commits — parity with `SignoffGate.transaction()` — so a protected record committed inside a batch can no longer leave the anchor at the pre-batch head, where a later tail-truncation would go undetected (legis-0c310712a7; PRD-0005 criterion 2).
+
+**Architecture:** `ProtectedGate._record_signed()` (`protected.py:286-292`) correctly defers the per-append anchor advance when `self._store.in_batch()` is true (a mid-batch head read is batch-forbidden, Q-M5), trusting a batch *owner* to advance the anchor after commit — but `ProtectedGate` has no such owner, unlike `SignoffGate.transaction()` (`signoff.py:177-189`). This plan adds the missing owner: a `ProtectedGate.transaction()` `@contextmanager` that wraps `self._store.transaction()` and, after commit, calls `self._anchor.update(*self._store.get_latest_sequence_and_hash())`. It is a near-verbatim mirror of the sign-off precedent.
+
+**Threat reality (state it honestly — this is a DOUBLY-LATENT/preventive fix, not a live-exploited path):** grounding the current callers shows **no code path batches protected appends today, and production wires no anchor at all**. `route_findings` (`route_findings` spans `wardline/governor.py:46-177`; `txn_owner` is chosen at `117-122`) sets its `txn_owner` to `signoff` or `engine` and routes only BLOCK_ESCALATE / SURFACE_OVERRIDE / SURFACE_ONLY — there is **no protected cell in the governor**. Both transports **construct `ProtectedGate` with `anchor=None`** (the constructors at `api/app.py:376-379` and `mcp.py:266-269`) and call `operator_override()`/`submit()` outside any batch. So today the gap is *doubly* latent: the `_record_signed` anchor guard at `protected.py:291` never advances anything because **the anchor is absent (`None`), not merely unadvanced**, AND no caller opens a batch around a protected append. The exposure the finding names is a *future* deployment that both wires an anchor AND wraps a protected append in an `AuditStore.transaction()` with no owner to advance the anchor — for which there is currently no safe API. This fix supplies that API (parity), so the value is **entirely preventive**.
+
+**PRD-0005 overlap question — RESOLVED by grounding:** PRD-0005 flagged a possible "shared store-transaction wrapper used by both posture and protected." There is none: `route_findings`'s batch owner is `signoff`/`engine` (never protected), and the posture ledger is a **separate** `AuditStore` (`src/legis/posture/ledger.py`) with its own writes. This finding is isolated from legis-476ab6f125 (already closed) and from posture; sequencing is unaffected.
+
+**Tech Stack:** Python 3.12, SQLAlchemy Core over SQLite, `uv`, pytest. No new dependencies.
+
+**Prerequisites:**
+- Work on a feature branch / worktree, NOT `main` (e.g. `git switch -c fix/protected-batch-headanchor`). Per the authority grant, the merge to main + any publish is owner-gated; this plan ends at a green branch + an accepted finding.
+- `uv sync --dev` already run; `.venv` present.
+- Read context (already grounded): `src/legis/enforcement/protected.py` (`ProtectedGate.__init__` 207-239 → `self._store`/`self._anchor`/`self._key`; `_record_signed` 241-301 with the `in_batch()` anchor guard at 291; `operator_override` 389-413 — the deterministic, judge-free append path the tests use), `src/legis/enforcement/signoff.py:177-189` (the `transaction()` precedent to mirror VERBATIM), `src/legis/store/head_anchor.py` (`HeadAnchor.update`/`check`, `AnchorError`), `src/legis/store/audit_store.py` (`transaction` 180, `in_batch` 214, `get_latest_sequence_and_hash` 444, append-only triggers `audit_log_no_update`/`audit_log_no_delete` at 166/173), `tests/store/test_head_anchor.py:42-72` (the raw-truncation helper + `test_anchor_detects_tail_truncation` precedent), `tests/store/test_batch_read_free_invariant.py:27-31` (the on-disk-store fixture), `tests/enforcement/test_protected_submit.py` (the existing `ProtectedGate` construction fixtures).
+
+**Scope fence (PRD-0005 non-goals):** Do NOT modify `src/legis/store/audit_store.py`, `head_anchor.py`, or `canonical.py` (the cross-tool HMAC + anchor contracts). Do NOT re-architect the enforcement cell. Do NOT add a protected cell to `route_findings` (out of scope — and routing-coupling is a separate design question). The change is confined to `ProtectedGate` (add `transaction()` + one import) + two tests.
+
+---
+
+### Task 1: add ProtectedGate.transaction() that advances the anchor after commit
+
+**Files:**
+- Modify: `src/legis/enforcement/protected.py` — add `from contextlib import contextmanager` to the imports, and add a `transaction()` method on `ProtectedGate` (mirroring `signoff.py:177-189`).
+- Test (new): `tests/enforcement/test_protected_transaction.py`
+
+**Step 1: Write the failing test**
+
+Create `tests/enforcement/test_protected_transaction.py`. Uses the raw-truncation pattern from `tests/store/test_head_anchor.py:42-45` and the on-disk store from `tests/store/test_batch_read_free_invariant.py`. `operator_override` is the deterministic append (it bypasses the judge — `protected.py:400-413`), so a stub judge that is never consulted is correct.
+
+```python
+import sqlite3
+
+import pytest
+
+from legis.clock import FixedClock
+from legis.enforcement.protected import ProtectedGate
+from legis.enforcement.verdict import Verdict
+from legis.identity.entity_key import EntityKey
+from legis.store.audit_store import AuditStore
+from legis.store.head_anchor import AnchorError, HeadAnchor
+
+KEY = b"k" * 32
+CLOCK = "2026-06-26T12:00:00+00:00"
+
+
+class _UnusedJudge:
+ """operator_override bypasses the judge; if it is consulted, fail loudly."""
+
+ def evaluate(self, record): # pragma: no cover - must never be called
+ raise AssertionError("judge must not be consulted on operator_override")
+
+
+def _anchored_gate(tmp_path):
+ store = AuditStore(f"sqlite:///{tmp_path / 'gov.db'}")
+ anchor = HeadAnchor(str(tmp_path / "gov.anchor"), KEY)
+ gate = ProtectedGate(store, FixedClock(CLOCK), _UnusedJudge(), KEY, anchor=anchor)
+ return store, anchor, gate
+
+
+def _override(gate, rationale):
+ return gate.operator_override(
+ policy="protected/secrets",
+ entity_key=EntityKey.from_locator("m.f"),
+ rationale=rationale,
+ operator_id="op",
+ file_fingerprint="fp",
+ ast_path="m.f",
+ )
+
+
+def _truncate_to(db_path, keep_seq):
+ """Raw out-of-band tail truncation (drops the append-only triggers first)."""
+ con = sqlite3.connect(db_path)
+ try:
+ con.execute("DROP TRIGGER IF EXISTS audit_log_no_update")
+ con.execute("DROP TRIGGER IF EXISTS audit_log_no_delete")
+ con.execute("DELETE FROM audit_log WHERE seq > ?", (keep_seq,))
+ con.commit()
+ finally:
+ con.close()
+
+
+def test_protected_transaction_advances_anchor_so_truncation_is_detected(tmp_path):
+ """A protected append batched through ProtectedGate.transaction() advances the
+ HeadAnchor after commit, so a later tail-truncation back to the pre-batch head
+ is DETECTED (parity with SignoffGate.transaction()). legis-0c310712a7.
+ """
+ store, anchor, gate = _anchored_gate(tmp_path)
+ # A pre-batch protected append (outside any batch -> anchor advances normally).
+ _override(gate, "pre-batch")
+ pre_seq, _ = store.get_latest_sequence_and_hash()
+
+ # Batch a protected append through the NEW owned transaction API.
+ with gate.transaction():
+ _override(gate, "in-batch")
+
+ # The transaction() advanced the anchor to the in-batch record's head.
+ head_seq, _ = store.get_latest_sequence_and_hash()
+ assert head_seq == pre_seq + 1
+
+ # Truncate the in-batch record out of band, back to the pre-batch head.
+ _truncate_to(str(tmp_path / "gov.db"), pre_seq)
+
+ # The anchor remembers the higher head -> truncation is detected.
+ with pytest.raises(AnchorError):
+ anchor.check(store.read_all())
+```
+
+**Why this test:** It pins the finding's fix — the owned transaction API advances the anchor so a batched protected append is truncation-detectable. It exercises the real out-of-band attacker (raw `sqlite3` truncation), not a mock.
+
+**Step 2: Run test to verify it fails**
+
+Run: `uv run pytest tests/enforcement/test_protected_transaction.py::test_protected_transaction_advances_anchor_so_truncation_is_detected -v`
+
+Expected output (RED — the finding's exact gap, "ProtectedGate has no transaction wrapper"):
+```
+FAILED ... AttributeError: 'ProtectedGate' object has no attribute 'transaction'
+```
+
+**Step 3: Write minimal implementation**
+
+In `src/legis/enforcement/protected.py`: add the import (top of file, with the other stdlib imports) —
+```python
+from contextlib import contextmanager
+```
+— and add this method to `ProtectedGate` (place it next to `verify_integrity`/`records`, e.g. after `operator_override`). It is a verbatim mirror of `SignoffGate.transaction()` (`signoff.py:177-189`):
+
+```python
+ @contextmanager
+ def transaction(self):
+ """Group this gate's protected appends into one all-or-nothing batch and
+ advance the anchor once after commit — parity with
+ ``SignoffGate.transaction()`` (signoff.py).
+
+ The per-append anchor advance is deferred inside a batch (the head read
+ is batch-forbidden, Q-M5; see the ``in_batch()`` guard in
+ ``_record_signed``). Advance it once here after the batch commits and the
+ write lock is released. An exception inside the batch rolls back and
+ propagates before this runs, so the anchor never advances past a
+ rolled-back head (AUD-1: the anchor only ever lags, never overshoots).
+ """
+ with self._store.transaction():
+ yield
+ if self._anchor is not None:
+ self._anchor.update(*self._store.get_latest_sequence_and_hash())
+```
+
+**Why minimal:** This is the exact `SignoffGate` precedent — no new anchor logic, no change to `_record_signed` (its `in_batch()` deferral is already correct; this supplies the missing owner that re-advances after commit). `audit_store.py` / `head_anchor.py` are untouched.
+
+**Step 4: Run test to verify it passes**
+
+Run: `uv run pytest tests/enforcement/test_protected_transaction.py::test_protected_transaction_advances_anchor_so_truncation_is_detected -v`
+
+Expected output:
+```
+PASSED
+```
+
+**Step 5: Commit**
+
+```bash
+git add src/legis/enforcement/protected.py tests/enforcement/test_protected_transaction.py
+git commit -m "fix(protected): add ProtectedGate.transaction() that advances the anchor
+
+A protected record appended inside a batch defers its HeadAnchor advance
+(the mid-batch head read is forbidden, Q-M5) and, unlike SignoffGate, had no
+transaction owner to re-advance it after commit — so a batched protected
+append could leave the anchor at the pre-batch head and a later tail-truncation
+would go undetected. Add ProtectedGate.transaction(), a verbatim mirror of
+SignoffGate.transaction(), as that owner.
+
+Closes legis-0c310712a7 (PRD-0005 criterion 2).
+
+Co-Authored-By: Claude Opus 4.8 (1M context) "
+```
+
+**Definition of Done:**
+- [ ] Test written and fails for the right reason (AttributeError: no `transaction`)
+- [ ] `from contextlib import contextmanager` added; `ProtectedGate.transaction()` added, mirroring `signoff.py:177-189`
+- [ ] Test passes post-fix
+- [ ] No other tests broken
+- [ ] Committed
+
+---
+
+### Task 2: document the raw-batch parity residual (characterization test)
+
+**Files:**
+- Test (new): `tests/enforcement/test_protected_transaction.py` (add one test — no production change)
+
+**Step 1: Write the documentation test**
+
+This PASSES on the Task-1 code — it asserts a KNOWN LIMIT (parity with `SignoffGate`), so it is the honesty tripwire: a protected append inside a **raw** `store.transaction()` (bypassing `gate.transaction()`) still leaves the anchor stale, because nobody advances it after commit. The supported safe path is `gate.transaction()`; a raw batch owner must advance the anchor itself. This mirrors the residual-pinning discipline used for legis-476ab6f125.
+
+```python
+def test_raw_store_transaction_bypasses_anchor_advance_documented_residual(tmp_path):
+ """DOCUMENTS the parity limit (not a defense): a protected append inside a RAW
+ store.transaction() — bypassing gate.transaction() — defers the anchor advance
+ (in_batch() is true) and nothing re-advances it, so a truncation back to the
+ pre-batch head is NOT detected. The supported safe path is gate.transaction()
+ (Task 1); a caller that owns a raw batch must advance the anchor itself,
+ identically to SignoffGate. If a future change closes this (e.g. an after-commit
+ hook on AuditStore.transaction), update this test deliberately. legis-0c310712a7.
+ """
+ store, anchor, gate = _anchored_gate(tmp_path)
+ _override(gate, "pre-batch")
+ pre_seq, _ = store.get_latest_sequence_and_hash()
+
+ # RAW batch (NOT gate.transaction()): the append's anchor advance is deferred
+ # and never re-applied.
+ with store.transaction():
+ _override(gate, "in-raw-batch")
+
+ # The anchor is STALE at the pre-batch head (the residual).
+ _truncate_to(str(tmp_path / "gov.db"), pre_seq)
+
+ # Stale anchor == truncated DB head -> truncation is NOT detected.
+ anchor.check(store.read_all()) # does not raise: asserts the DOCUMENTED residual
+```
+
+**Why this test:** It makes the parity limit executable so it is never implied-closed — and, paired with Task 1, it is the before/after: raw batch → stale anchor → undetected; `gate.transaction()` → advanced anchor → detected.
+
+**Step 2: Run test to verify it passes**
+
+Run: `uv run pytest "tests/enforcement/test_protected_transaction.py::test_raw_store_transaction_bypasses_anchor_advance_documented_residual" -v`
+
+Expected output:
+```
+PASSED
+```
+
+(If it FAILS because `anchor.check` raised, the raw batch unexpectedly advanced the anchor — investigate; do NOT silence it by weakening the assertion.)
+
+**Step 3: Commit**
+
+```bash
+git add tests/enforcement/test_protected_transaction.py
+git commit -m "test(protected): pin the raw-batch anchor-advance residual
+
+A protected append inside a raw store.transaction() (bypassing gate.transaction())
+leaves the anchor stale — the supported safe path is gate.transaction(). This
+characterization test makes the parity limit visible so it is never implied-closed.
+
+Co-Authored-By: Claude Opus 4.8 (1M context) "
+```
+
+**Definition of Done:**
+- [ ] Documentation test added and passing
+- [ ] Name/docstring make clear it asserts a residual, not a guarantee
+- [ ] Committed
+
+---
+
+### Task 3: full verification (suite + coverage floor + gates)
+
+**Files:** none (verification only).
+
+**Step 1: Run the enforcement suite**
+
+Run: `uv run pytest tests/enforcement tests/store -q`
+
+Expected: all pass. The change is purely additive (a new method + import), so no existing test should change behavior. Watch for any test that constructs `ProtectedGate` positionally and could be affected by the new import (none expected).
+
+**Step 2: Per-package coverage floor**
+
+Run: `uv run pytest tests/enforcement --cov=legis.enforcement --cov-report=term-missing`
+then: `uv run python scripts/check_coverage_floors.py`
+
+Expected: the `enforcement/` per-package floor (see the `FLOORS` dict in `scripts/check_coverage_floors.py`) holds. The new `transaction()` method's body is covered by Task 1; the deferred-advance path (`in_batch()` true) by Task 2.
+
+**Step 3: CI-equivalent gates**
+
+Run, expecting all green:
+```bash
+uv run pytest --cov=legis --cov-fail-under=88
+uv run mypy src/legis
+uv run ruff check src
+uv run legis governance-gate
+```
+
+Expected: pytest passes with total coverage ≥ 88; mypy clean (the `@contextmanager` return type matches `SignoffGate.transaction`'s, which already type-checks); ruff clean; governance-gate passes.
+
+**Definition of Done:**
+- [ ] `tests/enforcement` + `tests/store` green
+- [ ] `enforcement/` per-package floor holds; global ≥ 88
+- [ ] mypy + ruff + governance-gate green
+- [ ] Branch ready for review (NOT merged — owner-gated)
+
+---
+
+## After execution — acceptance + closeout (product-owner, post-merge)
+
+Once the branch is green and merged (owner-gated), this finding is **accepted** against PRD-0005 criterion 2:
+- Close `legis-0c310712a7` in the tracker (walk confirmed→fixing→verifying→closed; `commit=main@`).
+- The north-star (`metrics.md`: open governance-honesty defects) drops **2 → 1**.
+- Then plan the last finding (`legis-0186c23a2c`, the unbounded policy-boundary root) — the third and final PRD-0005 item; no overlap with this fix.
+
+## Validate before execution (recommended — security-critical)
+
+This is a governance-honesty security fix on a coverage-floored package. **RECOMMENDED:** run `/review-plan docs/plans/2026-06-26-protected-batch-headanchor-transaction.md` (reality/architecture/quality/systems) — or the same ultracode multi-agent review used for legis-476ab6f125 — before execution. Reviewer attention points: (1) confirm `route_findings` truly has no protected cell (the "latent" framing rests on it); (2) confirm `operator_override` is judge-free so the stub judge is sound; (3) confirm the `_truncate_to` trigger-drop + delete reproduces a real out-of-band truncation that `HeadAnchor.check` detects; (4) confirm the new method is a faithful mirror of `SignoffGate.transaction()` with no anchor-overshoot risk.
+
+## Review fold-ins (round-1 multi-agent review: APPROVED_WITH_WARNINGS — fold in during execution, none block)
+
+A 7-agent + synthesizer review returned **GO** with these non-blocking improvements; apply them as you execute:
+
+1. **(rank 1, medium — the highest-value gap) Add a production-default UNANCHORED test in Task 1.** Both transports wire `anchor=None`, so the only production-reachable path of `transaction()` is the `self._anchor is None` no-op arm — and both new tests use `_anchored_gate()`, so that arm is untested (line-coverage floors won't catch it; `pyproject` has no `branch=True`). Add:
+ ```python
+ def test_protected_transaction_is_safe_without_an_anchor(tmp_path):
+ """The production default: ProtectedGate has anchor=None. transaction()
+ must still batch atomically (the if-anchor guard is a no-op, not a crash)."""
+ store = AuditStore(f"sqlite:///{tmp_path / 'gov.db'}")
+ gate = ProtectedGate(store, FixedClock(CLOCK), _UnusedJudge(), KEY) # no anchor=
+ with gate.transaction():
+ _override(gate, "in-batch")
+ assert len(store.read_all()) == 1
+ assert store.verify_integrity() is True
+ ```
+2. **(rank 2, low) Task 2 — add a landing assertion** after the raw `with store.transaction(): _override(...)` and before `_truncate_to`: `assert store.get_latest_sequence_and_hash()[0] == pre_seq + 1` — proves the `in_batch()`-deferred branch was actually taken, so the residual test can't pass for the wrong reason (a silently no-op'd append). Keep the "do NOT silence by weakening the assertion" guidance.
+3. **(rank 3, low) Task 1 — fix the now-contradicting comment** in `_record_signed` (`protected.py:287-290`): it still says "the protected gate is not itself a batch owner." Bring it to `SignoffGate._append` parity (`signoff.py:110-111`): the per-append advance is deferred inside a batch (Q-M5) and `ProtectedGate.transaction()` advances it once after commit. No production-logic change.
+4. **(rank 5, low) Task 1 — comment `_truncate_to`**: `# No survivor re-chain needed: AnchorError fires on the head_seq comparison before chain_hash is checked.` Optionally `assert not store.verify_integrity()` after the truncation to make the broken-chain state explicit.
+5. **(rank 6, low) `transaction()` docstring — add a nesting caveat**: `gate.transaction()` must be the OUTERMOST batch owner for its store; nesting it inside another gate's transaction on the same thread raises `RuntimeError` (fail-closed, the batch-forbidden read — inherited from the `SignoffGate` contract). Documentation only.
+6. **(rank 7, low, OPTIONAL) consider an AUD-1 no-overshoot rollback test**: open an anchored `gate.transaction()`, `_override` inside, `raise` before exit, catch, then assert `len(store.read_all()) == pre_batch_count` and `anchor.check(store.read_all())` does not raise — makes the docstring's no-overshoot claim executable.
+7. **(Task 3 DoD — real gap) Add the two CI gates the plan omitted** (CLAUDE.md lists them): `uv run legis policy-boundary-check --root src --repo-root .` and `uv run pytest tests/conformance/test_sei_oracle.py`. A pure-additive enforcement contextmanager is very unlikely to trip either, but the "all CI gates green" DoD requires them.
diff --git a/docs/plans/2026-06-26-warpline-preflight-mcp-transport.md b/docs/plans/2026-06-26-warpline-preflight-mcp-transport.md
new file mode 100644
index 0000000..bd46869
--- /dev/null
+++ b/docs/plans/2026-06-26-warpline-preflight-mcp-transport.md
@@ -0,0 +1,360 @@
+# Warpline Preflight: MCP-Stdio Transport + Real Envelope — Implementation Plan
+
+> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
+
+**Goal:** Replace legis's phantom-HTTP warpline preflight client with a minimal stdio JSON-RPC client that consumes the **extant** `warpline_impact_radius_get` / `warpline_reverify_worklist_get` MCP tools with `rev_range`, parses warpline's **real frozen envelope** (`warpline.impact_radius.v1` / `warpline.reverify_worklist.v1`), verifies the GV-LG-3 `meta` invariant, fails SAFE on every fault, and keeps the advisory boundary byte-identical — so the sanctioned SEAM 4 §4A preflight seam actually works against real warpline (legis-a53d92507d). warpline reimplements nothing; legis conforms to what warpline already serves.
+
+**Architecture:** Today `HttpWarplineClient` (`src/legis/warpline_preflight/client.py`) issues `GET {WARPLINE_API_URL}/api/impact-radius` over `urllib` — a wire **warpline never served** (it has only MCP + CLI). The hub interface-lock SEAM 4 §4A pins the seam to "*same wire shape as `warpline_impact_radius_get`*" (the envelope), and **GV-LG-3** requires legis to read `meta.local_only`/`meta.peer_side_effects` off that envelope (the flat shape has no `meta`). So the transport becomes **MCP-stdio** (owner-confirmed 2026-06-26): a subprocess speaks JSON-RPC over stdio to `warpline-mcp`, calls one tool per verb with `rev_range=".."`, and returns the parsed envelope. The fix preserves the seams that are CORRECT: the `WarplineClient` Protocol (`impact_radius`/`reverify_worklist` → dict), the injectable-transport pattern (today `fetch`; now an injectable `invoke`), and `read_warpline_preflight`'s fail-safe discipline (`service/preflight.py`: `None`/`WarplineError` → `unavailable`, never an empty affected-set).
+
+**Threat model shift (HTTP → local process):** the HTTP SSRF / redirect / TLS surface (`_validate_base_url`, `_is_loopback`, `_open_no_redirect`, `_NoRedirectHandler`, and the filigree-clone test) is **moot** and is removed. The new surface is local-process: (1) **no shell, list-argv** — `rev_range` travels as a JSON-RPC *param*, never as a CLI argument, so `shell=False` + a list `argv` makes argument/shell injection structurally impossible (note: `WARPLINE_MCP_CMD="warpline-mcp"` IS an implicit `PATH` lookup — `shell=False` eliminates *shell* injection, not PATH ambiguity; **recommend an absolute path**, and reject an empty command); (2) **output bound** — `subprocess.run` buffers stdout, so the size check is **post-capture + timeout-bounded** (a true *incremental* read bound requires the Popen variant — see Task 2's escalation note; the cap is on **bytes**, `text=False`); (3) **timeout** — bound the child (10s) and let `TimeoutExpired` → `WarplineError`; (4) **isolation** — a missing exe / crash / non-zero exit / garbage / oversize / timeout is a `WarplineError` → `unavailable`, never an escape, and the error carries the child's `returncode` + truncated `stderr` for operator diagnosis.
+
+**Tech Stack:** Python 3.12, stdlib `subprocess` + `json` (NO new dependency — legis's sibling-client ethos), `uv`, pytest.
+
+**Prerequisites:**
+- Work on a feature branch / worktree, NOT `main` (e.g. `git switch -c fix/warpline-preflight-mcp`). The merge + any 1.3.0 publish is owner-gated; **do NOT release 1.3.0 with the old mis-frozen seam**.
+- `uv sync --dev` already run.
+- **A live `warpline-mcp` is REQUIRED for Task 2's DoD gate** (capture one real session transcript — see Task 2). Confirm it is runnable; if it is not reachable, Task 2 cannot complete its gate and you escalate before Task 3 (do not ship an unverified transport).
+- Read context (grounded): `src/legis/warpline_preflight/client.py` (the `WarplineClient` Protocol 34-37, `WarplineError` 27, `HttpWarplineClient` 118-143, `MAX_RESPONSE_BYTES` 31, the `fetch` seam at client.py:76 doing the real incremental `resp.read(MAX_RESPONSE_BYTES+1)` bound the new path loosens — call that out), `src/legis/service/preflight.py` (the fail-safe pass-through — KEEP; calls BOTH verbs at 27-28), `src/legis/mcp.py:231-243` (the `WARPLINE_API_URL` wiring) + `:893-905` (preflight output schema — bare `{"type":"object"}`, no downstream ripple), `tests/warpline_preflight/test_client.py` (HTTP-specific; rewritten), `tests/warpline_preflight/fixtures/{warpline-preflight-golden.json,PROVENANCE.md}` + `test_warpline_preflight_oracle.py` (the mis-frozen flat golden + inverted obligation + `GOLDEN_BLOB_SHA` byte-pin at oracle:49 + the deleted-symbol imports `HttpWarplineClient`/`_decode_json_response`), `tests/mcp/test_warpline_advisory_boundary.py:74-99` (`_HostileWarpline` + byte-identity test) **and `:143-174`** (the structural boundary test derived from `_TOOL_HANDLERS` — a load-bearing invariant to PRESERVE), `tests/mcp/test_output_schema_conformance.py:635-639` (flat stub), `tests/mcp/test_server.py:3388,3397` (an `HttpWarplineClient` import + an `isinstance` assertion that must become `WarplineMcpClient`).
+
+**warpline's REAL surface (authoritative — verified vs warpline source 2026-06-26; treat as the contract):**
+- MCP tools (stdio, `warpline-mcp`): `warpline_impact_radius_get` (shim `blast_radius`) → `warpline.impact_radius.v1`; `warpline_reverify_worklist_get` (shim `reverify`) → `warpline.reverify_worklist.v1`. Both accept `arguments.rev_range=".."`.
+- Envelope: `{schema, ok:true, query, data, warnings, next_actions, enrichment, meta}`. impact: `data.affected` (list); reverify: `data.items` (NOT `entries`). **No top-level `count`.** `data.completeness` + `data.staleness` mandatory honesty fields. `meta.local_only` + `meta.peer_side_effects` = the GV-LG-3 invariant.
+
+**Scope fence:** Do NOT change a federation contract (CONFORMS to the already-frozen SEAM 4 §4A). Do NOT let warpline output reach a governance verdict path (advisory-only). Do NOT freeze a `reverify_worklist` dependency before wardline rules (§2A names filigree as the reverify consumer; reverify stays keep-or-drop). Do NOT touch `service/preflight.py`'s fail-safe discipline. Do NOT add a dependency (unless the Task-2 escalation explicitly approves the `mcp` SDK).
+
+---
+
+### Task 1: domain client `WarplineMcpClient` over an injectable `invoke` seam (envelope parse + GV-LG-3 + degraded-floor, all fail-closed)
+
+**Files:** Rewrite `src/legis/warpline_preflight/client.py` (keep `WarplineClient` Protocol + `WarplineError` + `MAX_RESPONSE_BYTES`; replace `HttpWarplineClient` and all HTTP helpers with `WarplineMcpClient` + an `Invoke` seam; the real stdio invoker is Task 2). Rewrite `tests/warpline_preflight/test_client.py` (drop every HTTP test incl. the filigree-clone test; new tests inject a fake `invoke`).
+
+**Step 1: failing tests** (offline — inject a recorder `invoke`). Include the fault paths so guardrail-(b) is TESTED, not just asserted:
+
+```python
+import pytest
+from legis.warpline_preflight.client import WarplineMcpClient, WarplineClient, WarplineError
+
+_VALID_META = {"local_only": True, "peer_side_effects": []}
+_KEEP = object() # sentinel: "use the valid default meta" — DISTINCT from None (None IS a test case)
+def _env(schema, data_key, items, *, meta=_KEEP, completeness="FULL"):
+ data = {data_key: items, "staleness": {"commits_behind": 0}}
+ if completeness is not None:
+ data["completeness"] = completeness
+ return {"schema": schema, "ok": True, "query": {"rev_range": "aaa..bbb"}, "data": data,
+ "warnings": [], "next_actions": {}, "enrichment": {"sei": "present"},
+ "meta": dict(_VALID_META) if meta is _KEEP else meta} # meta=None -> {"meta": None}, a real case
+
+def _recorder(responses):
+ calls = []
+ def invoke(tool, arguments):
+ calls.append((tool, arguments)); return responses.pop(0)
+ invoke.calls = calls; return invoke
+
+def test_protocol_is_runtime_checkable():
+ assert isinstance(WarplineMcpClient(invoke=_recorder([{}])), WarplineClient)
+
+def test_impact_radius_calls_tool_with_rev_range_and_passes_envelope_through():
+ e = _env("warpline.impact_radius.v1", "affected", [{"sei": "loomweave:eid:" + "a"*32}])
+ inv = _recorder([e]); out = WarplineMcpClient(invoke=inv).impact_radius("aaa", "bbb")
+ assert out == e
+ assert inv.calls[0] == ("warpline_impact_radius_get", {"rev_range": "aaa..bbb"})
+
+def test_reverify_calls_reverify_tool():
+ e = _env("warpline.reverify_worklist.v1", "items", [])
+ inv = _recorder([e]); WarplineMcpClient(invoke=inv).reverify_worklist("a", "b")
+ assert inv.calls[0][0] == "warpline_reverify_worklist_get"
+
+@pytest.mark.parametrize("bad", [["not", "dict"], "str", 7, None])
+def test_non_dict_envelope_is_warpline_error(bad):
+ with pytest.raises(WarplineError):
+ WarplineMcpClient(invoke=_recorder([bad])).impact_radius("a", "b")
+
+def test_wrong_schema_or_not_ok_is_warpline_error():
+ wrong = _env("warpline.reverify_worklist.v1", "items", []) # wrong schema for impact
+ with pytest.raises(WarplineError, match="schema"):
+ WarplineMcpClient(invoke=_recorder([wrong])).impact_radius("a", "b")
+ notok = _env("warpline.impact_radius.v1", "affected", []); notok["ok"] = False
+ with pytest.raises(WarplineError, match="ok"):
+ WarplineMcpClient(invoke=_recorder([notok])).impact_radius("a", "b")
+
+def test_gv_lg_3_hostile_or_malformed_meta_is_refused_fail_closed():
+ e = _env("warpline.impact_radius.v1", "affected", []); e["meta"] = {"local_only": True, "peer_side_effects": ["did_a_thing"]}
+ with pytest.raises(WarplineError, match="side effect"):
+ WarplineMcpClient(invoke=_recorder([e])).impact_radius("a", "b")
+ for bad_meta in ({"local_only": False, "peer_side_effects": []}, {"peer_side_effects": []}, "not-a-dict", None, 5):
+ em = _env("warpline.impact_radius.v1", "affected", [], meta=bad_meta)
+ with pytest.raises(WarplineError): # non-dict / missing / False local_only all refuse
+ WarplineMcpClient(invoke=_recorder([em])).impact_radius("a", "b")
+
+def test_degraded_envelope_missing_completeness_is_warpline_error():
+ e = _env("warpline.impact_radius.v1", "affected", [], completeness=None) # completeness omitted
+ with pytest.raises(WarplineError, match="completeness"):
+ WarplineMcpClient(invoke=_recorder([e])).impact_radius("a", "b")
+```
+
+**Step 2: RED** (`WarplineMcpClient` missing). **Step 3: implement.** Delete `HttpWarplineClient`, `_urllib_fetch`, `_open_no_redirect`, `_NoRedirectHandler`, `_decode_json_response`, `_is_loopback`, `_validate_base_url`, and the `urllib`/`http.client`/`ipaddress` imports. Keep `WarplineError`, `MAX_RESPONSE_BYTES`, the Protocol. Add:
+
+```python
+Invoke = Callable[[str, "dict[str, Any]"], "Any"] # returns the parsed tool result (validated below)
+_IMPACT = ("warpline.impact_radius.v1", "warpline_impact_radius_get")
+_REVERIFY = ("warpline.reverify_worklist.v1", "warpline_reverify_worklist_get")
+
+class WarplineMcpClient:
+ """Consume warpline's EXTANT MCP tools (advisory preflight). Pass the frozen
+ envelope through verbatim (the bare-object MCP output schema makes pass-through
+ lossless). Advisory-ONLY; every contract fault fails CLOSED -> WarplineError."""
+ def __init__(self, *, invoke: "Invoke") -> None:
+ self._invoke = invoke
+ def impact_radius(self, base: str, head: str) -> dict[str, Any]:
+ return self._call(*_IMPACT, base, head)
+ def reverify_worklist(self, base: str, head: str) -> dict[str, Any]:
+ return self._call(*_REVERIFY, base, head)
+ def _call(self, schema: str, tool: str, base: str, head: str) -> dict[str, Any]:
+ env = self._invoke(tool, {"rev_range": f"{base}..{head}"})
+ if not isinstance(env, dict):
+ raise WarplineError(f"{tool} returned {type(env).__name__}, expected an envelope object")
+ if env.get("schema") != schema:
+ raise WarplineError(f"{tool} returned schema {env.get('schema')!r}, expected {schema!r}")
+ if env.get("ok") is not True:
+ raise WarplineError(f"{tool} envelope is not ok=true: {env.get('ok')!r}")
+ meta = env.get("meta")
+ if not isinstance(meta, dict): # malformed meta fails closed (GV-LG-3 input)
+ raise WarplineError(f"{tool} envelope meta is {type(meta).__name__}, expected an object")
+ if meta.get("local_only") is not True:
+ raise WarplineError(f"{tool} meta.local_only is not true: {meta.get('local_only')!r}")
+ if meta.get("peer_side_effects"):
+ raise WarplineError(f"{tool} claims a peer side effect (GV-LG-3): {meta.get('peer_side_effects')!r}")
+ data = env.get("data")
+ if not isinstance(data, dict) or "completeness" not in data: # degraded -> unavailable, not bare empty 'checked'
+ raise WarplineError(f"{tool} envelope data is missing the mandatory 'completeness' field")
+ return env
+```
+
+**Why these checks:** `isinstance(meta, dict)` closes the non-dict-meta escape (a truthy non-dict would otherwise `AttributeError` past the GV-LG-3 gate). Requiring `data.completeness` means a *degraded* warpline degrades to `unavailable` rather than a bare empty `checked` (rank-8 honesty floor; `staleness` is surfaced via pass-through). Pass-through keeps `service/preflight.py` and the bare-object output schema unchanged. `impact_radius`/`reverify_worklist` stay independent so reverify is keep-or-drop.
+
+**Step 4: GREEN. Step 5: commit.** **DoD:** parses `data.affected`/`data.items` via pass-through; schema/ok/meta(incl. non-dict)/completeness all fail closed to `WarplineError`; HTTP code + filigree-clone test deleted; Protocol/Error/cap preserved; the fault tests above are GREEN; committed.
+
+---
+
+### Task 2: production stdio JSON-RPC invoker (`StdioMcpInvoke`) — hardened fail-safe + a LIVE-capture DoD gate
+
+**Files:** Modify `client.py` (add `StdioMcpInvoke` + `_read_jsonrpc_result`). Test `tests/warpline_preflight/test_stdio_invoke.py` (new).
+
+**Step 1: failing tests** — drive the real subprocess+stdio path against tiny fake `warpline-mcp` stub scripts in `tmp_path`, AND (DoD gate) against a captured **live** transcript. Every fault asserts `WarplineError`:
+
+```python
+import sys, json, textwrap, pytest
+from legis.warpline_preflight.client import StdioMcpInvoke, WarplineError
+
+def _script(tmp_path, body):
+ p = tmp_path / "fake.py"; p.write_text(textwrap.dedent(body)); return [sys.executable, str(p)]
+
+_OK = '''
+ import sys, json
+ for line in sys.stdin:
+ m = json.loads(line); mid = m.get("id")
+ if m.get("method") == "initialize":
+ print(json.dumps({"jsonrpc":"2.0","id":mid,"result":{"protocolVersion":"2025-06-18","capabilities":{},"serverInfo":{"name":"f","version":"0"}}}), flush=True)
+ elif m.get("method") == "tools/call":
+ env = {"schema":"warpline.impact_radius.v1","ok":True,"query":m["params"]["arguments"],"data":{"affected":[{"sei":"x"}],"completeness":"FULL","staleness":{"commits_behind":0}},"warnings":[],"next_actions":{},"enrichment":{},"meta":{"local_only":True,"peer_side_effects":[]}}
+ print(json.dumps({"jsonrpc":"2.0","id":mid,"result":{"content":[{"type":"text","text":json.dumps(env)}],"structuredContent":env,"isError":False}}), flush=True)
+'''
+
+def test_round_trips_against_fake_server(tmp_path):
+ env = StdioMcpInvoke(command=_script(tmp_path, _OK))("warpline_impact_radius_get", {"rev_range": "a..b"})
+ assert env["data"]["affected"] == [{"sei": "x"}]
+
+def test_replays_a_REAL_captured_session(tmp_path):
+ """DoD GATE: this fixture is bytes captured from a live `warpline-mcp` session
+ (see PROVENANCE). A green here means the real message order + result shape
+ (structuredContent vs content[].text + protocolVersion) were exercised, not a
+ legis-shaped assumption. If no live capture exists, this test FAILS (xfail is
+ not allowed) and Task 2 is not done — escalate (see the gate note)."""
+ # Replace this body with bytes captured from a REAL warpline-mcp session.
+ # Until then it FAILS (not passes, not xfails) so the gate cannot be skipped:
+ pytest.fail("Wire a REAL captured warpline-mcp transcript here — Task 2 HARD DoD GATE; do NOT skip/xfail. See the gate note below.")
+
+@pytest.mark.parametrize("body,match", [
+ ('import sys\n', "no JSON-RPC response"), # empty stdout
+ ('print("not json", flush=True)\n', "non-JSON line"), # non-JSON line
+ ('import json;print(json.dumps({"jsonrpc":"2.0","id":2,"result":7}))\n', "result"), # scalar result
+ ('import json;print(json.dumps({"jsonrpc":"2.0","id":2,"result":{"isError":True,"content":[]}}))\n', "error result"), # isError
+ ('import json;print(json.dumps({"jsonrpc":"2.0","id":2,"error":{"code":-1,"message":"boom"}}))\n', "boom"), # jsonrpc error
+])
+def test_fault_paths_all_raise_warpline_error(tmp_path, body, match):
+ with pytest.raises(WarplineError, match=match):
+ StdioMcpInvoke(command=_script(tmp_path, body))("warpline_impact_radius_get", {"rev_range": "a..b"})
+
+def test_missing_executable_is_warpline_error(tmp_path):
+ with pytest.raises(WarplineError):
+ StdioMcpInvoke(command=[str(tmp_path / "nope")])("warpline_impact_radius_get", {"rev_range": "a..b"})
+
+def test_empty_command_is_warpline_error():
+ with pytest.raises(WarplineError, match="empty"):
+ StdioMcpInvoke(command=[])("warpline_impact_radius_get", {"rev_range": "a..b"})
+
+def test_timeout_is_warpline_error(tmp_path):
+ with pytest.raises(WarplineError):
+ StdioMcpInvoke(command=_script(tmp_path, "import time;time.sleep(5)\n"), timeout=0.3)("warpline_impact_radius_get", {"rev_range": "a..b"})
+
+def test_oversize_stdout_is_warpline_error(tmp_path):
+ body = 'import sys;sys.stdout.buffer.write(b"x"*2_000_000)\n'
+ with pytest.raises(WarplineError, match="too large"):
+ StdioMcpInvoke(command=_script(tmp_path, body))("warpline_impact_radius_get", {"rev_range": "a..b"})
+```
+
+**Step 2: RED. Step 3: implement** — list-argv, no shell, `text=False` (so the cap is a BYTE count), empty-argv rejected, the ENTIRE post-spawn parse wrapped so any fault → `WarplineError`, and `stderr`/`returncode` surfaced:
+
+```python
+import subprocess
+
+def _read_jsonrpc_result(stdout_text: str, response_id: int) -> dict:
+ for line in stdout_text.splitlines():
+ line = line.strip()
+ if not line:
+ continue
+ try:
+ msg = json.loads(line)
+ except ValueError as exc:
+ raise WarplineError(f"warpline-mcp emitted a non-JSON line: {exc}") from exc
+ if isinstance(msg, dict) and msg.get("id") == response_id:
+ if "error" in msg:
+ raise WarplineError(f"warpline-mcp returned a JSON-RPC error: {msg['error']}")
+ result = msg.get("result")
+ if not isinstance(result, dict):
+ raise WarplineError(f"warpline-mcp result is {type(result).__name__}, expected an object")
+ return result
+ raise WarplineError(f"warpline-mcp produced no JSON-RPC response for id={response_id}")
+
+class StdioMcpInvoke:
+ """Production Invoke: a stdio JSON-RPC call to warpline-mcp. Fail-safe: EVERY
+ fault -> WarplineError. shell=False + list argv (rev_range is a JSON param, never
+ an argv token); explicit command (absolute path recommended; empty rejected);
+ text=False byte-bounded stdout (post-capture; see the cap note); 10s timeout."""
+ def __init__(self, *, command: list[str], timeout: float = 10.0) -> None:
+ self._command = command
+ self._timeout = timeout
+ def __call__(self, tool: str, arguments: dict) -> dict:
+ if not self._command:
+ raise WarplineError("warpline-mcp command is empty (WARPLINE_MCP_CMD blank?)")
+ msgs = (
+ {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"legis","version":"1"}}},
+ {"jsonrpc":"2.0","method":"notifications/initialized"},
+ {"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":tool,"arguments":arguments}},
+ )
+ stdin = ("".join(json.dumps(m) + "\n" for m in msgs)).encode("utf-8")
+ try:
+ proc = subprocess.run(self._command, input=stdin, capture_output=True,
+ timeout=self._timeout, shell=False, check=False) # text=False -> bytes
+ except (OSError, ValueError, subprocess.SubprocessError) as exc:
+ raise WarplineError(f"warpline-mcp spawn/timeout failed: {exc}") from exc
+ if len(proc.stdout) > MAX_RESPONSE_BYTES:
+ raise WarplineError("warpline-mcp response too large")
+ err = (proc.stderr or b"")[:400].decode("utf-8", "replace")
+ try:
+ result = _read_jsonrpc_result(proc.stdout.decode("utf-8", "replace"), response_id=2)
+ if result.get("isError"):
+ raise WarplineError(f"warpline tool {tool} returned an error result (rc={proc.returncode}, stderr={err!r})")
+ sc = result.get("structuredContent")
+ if isinstance(sc, dict):
+ return sc
+ for block in result.get("content") or []:
+ if isinstance(block, dict) and block.get("type") == "text":
+ return json.loads(block["text"])
+ raise WarplineError(f"warpline tool {tool} result had no usable envelope (rc={proc.returncode}, stderr={err!r})")
+ except WarplineError:
+ raise
+ except Exception as exc: # ANY parse fault fails closed
+ raise WarplineError(f"warpline tool {tool} result parse failed: {exc} (rc={proc.returncode}, stderr={err!r})") from exc
+```
+
+> **HARD DoD GATE (rank-3) — verify against a LIVE `warpline-mcp` before this task is done.** Capture one real session: send the three messages above to a real `warpline-mcp`, save its stdout transcript, and wire it into `test_replays_a_REAL_captured_session` (and into the golden capture for Task 4). Confirm: (a) the server **exits on stdin-EOF** (else `subprocess.run` blocks to timeout on every call); (b) it does **not** require interleaved I/O (read the `initialize` response before the client sends `tools/call`); (c) the real `protocolVersion`; (d) result shape (`structuredContent` vs `content[0].text`). **If (a) or (b) fails, STOP and escalate to the owner the `subprocess.run` → `Popen` (interleaved read-loop + true incremental byte bound) vs `mcp`-SDK-dependency decision BEFORE Task 3 — do not ship an unverified/flaky transport.** The Popen variant also delivers the real incremental read bound that the post-capture `subprocess.run` size check only approximates (the HTTP path's `resp.read(MAX_RESPONSE_BYTES+1)` was a true bound — this loosens it within the 10s window; the oversize test asserts the post-capture guard still fails closed).
+
+**Step 4: GREEN (fake + live replay). Step 5: commit.** **DoD:** the live-capture replay test is GREEN (or the escalation branch was taken); all fault variants raise `WarplineError`; no shell, list argv, empty-argv rejected, `text=False` byte cap, timeout, `stderr`/`returncode` in messages; committed.
+
+---
+
+### Task 3: rewire `mcp.py` — `WARPLINE_MCP_CMD` config + construction + fail-safe
+
+**Files:** Modify `src/legis/mcp.py:231-243`; update `tests/mcp/test_server.py:3388,3397` (the `HttpWarplineClient` import + the `isinstance(..., HttpWarplineClient)` assertion → `WarplineMcpClient`).
+
+Replace the `WARPLINE_API_URL` block (it's the wrong config for a non-HTTP transport):
+```python
+ warpline = None
+ warpline_cmd = os.environ.get("WARPLINE_MCP_CMD")
+ if warpline_cmd:
+ import shlex
+ from legis.warpline_preflight.client import StdioMcpInvoke, WarplineError, WarplineMcpClient
+ try:
+ argv = shlex.split(warpline_cmd)
+ if not argv:
+ raise WarplineError("WARPLINE_MCP_CMD is blank")
+ warpline = WarplineMcpClient(invoke=StdioMcpInvoke(command=argv), repo=_legis_repo_root())
+ except (WarplineError, ValueError) as exc:
+ logging.getLogger(__name__).warning(
+ "WARPLINE_MCP_CMD is set but invalid (%s); warpline advisory context "
+ "disabled (governance unaffected).", exc)
+ warpline = None
+```
+**(DISCOVERED at Task 2 — the live capture caught this; REQUIRED) Thread the `repo` argument.** Real `warpline_impact_radius_get` / `warpline_reverify_worklist_get` REQUIRE a `"repo"` argument (a non-empty repo path; without it warpline returns JSON-RPC `-32602 invalid params` → the client fail-safes to `WarplineError` → `unavailable`, i.e. the seam is dead-on-arrival again, just a different reason than the old HTTP one). So: (1) give `WarplineMcpClient.__init__` a `repo: str` param and have `_call` send `arguments={"repo": self._repo, "rev_range": f"{base}..{head}"}` (the live envelope confirms warpline echoes `query.repo` and fills `depth`/etc. server-side defaults). (2) Update the Task-1 `test_client.py` assertions that check `args == {"rev_range": ...}` to expect the `repo` key too. (3) In `mcp.py`, supply the repo value from **legis's existing repo-root resolution** — find it (`config.py` / the git surface / the runtime root; the captured session used `/home/john/legis`). Do NOT invent a config knob if legis already resolves its repo root — reuse it; replace the `_legis_repo_root()` placeholder above with the real resolver. This is the load-bearing reason the seam works end-to-end against real warpline.
+
+Note: construction only stores the command; a bad command's failure surfaces at *call* time (caught by `read_warpline_preflight` → `unavailable`). The empty-`shlex.split` case is rejected up front. **Tests:** unset `WARPLINE_MCP_CMD` → `warpline is None` → preflight `unavailable`; blank/`" "` → fail-safe `None`. Grep `tests/` for `WARPLINE_API_URL` and update; fix `test_server.py:3388/3397` (`HttpWarplineClient` → `WarplineMcpClient`). **Commit.** **DoD:** runtime builds `WarplineMcpClient(repo=...)` from `WARPLINE_MCP_CMD`; the `repo` arg is sent (sourced from legis's repo-root resolver) and the Task-1 tests assert it; unset/blank/invalid → `None` (fail-safe); no `WARPLINE_API_URL`/`HttpWarplineClient` in `src/`; `test_server.py` symbols updated; full `mypy src/legis` + `pytest` collection now succeed (the Task-1 cross-task breaks are resolved here); committed.
+
+---
+
+### Task 4: replace the mis-frozen fixtures with REAL envelopes; a NON-CIRCULAR oracle; reverse the producer-obligation
+
+**Files:** Replace `tests/warpline_preflight/fixtures/warpline-preflight-golden.json` (flat → real envelopes). Rewrite `fixtures/PROVENANCE.md`. Rewrite `tests/warpline_preflight/test_warpline_preflight_oracle.py`. Update `tests/mcp/test_warpline_advisory_boundary.py:74-99`. Update `tests/mcp/test_output_schema_conformance.py:635-639`.
+
+**(rank-1 — the load-bearing blocker) The oracle must be NON-CIRCULAR.** The whole point of this fix is that a golden must be validated by flowing through legis's REAL parse path with HARDCODED assertions — never `json.loads(golden)` re-parsed and asserted against itself. Specify it explicitly:
+```python
+def test_golden_flows_through_the_real_parser_with_hardcoded_assertions(tmp_path):
+ """Drive the FROZEN golden bytes through WarplineMcpClient._call (the real
+ schema/ok/meta/completeness validation), via a fake invoke replaying the bytes.
+ Assert HARDCODED values from the golden — NEVER a re-parse of the golden."""
+ golden = json.loads((FIX / "warpline-preflight-golden.json").read_text())
+ impact = WarplineMcpClient(invoke=lambda tool, args: golden["impact_radius"]).impact_radius("b", "h")
+ assert impact["schema"] == "warpline.impact_radius.v1"
+ assert impact["data"]["affected"][0]["sei"] == "loomweave:eid:" # hardcoded
+ assert impact["meta"]["local_only"] is True and impact["meta"]["peer_side_effects"] == []
+ assert impact["data"]["completeness"] == "FULL"
+ reverify = WarplineMcpClient(invoke=lambda tool, args: golden["reverify_worklist"]).reverify_worklist("b", "h")
+ assert reverify["data"]["items"][0]["sei"] == "loomweave:eid:" # hardcoded, NOT re-parsed
+```
+Keep a **Layer-1 byte-pin** (`GOLDEN_BLOB_SHA` at oracle:49 — **re-pin it** after the golden changes; self-catching via the byte-pin test) and a **Layer-2** `test_golden_matches_warpline_source` that points at **warpline's MCP contract fixture** (not the old REST path) and `pytest.skip`s cleanly when absent.
+
+**Capture the golden from a LIVE warpline run** (use the Task-2 transcript or `warpline blast-radius/reverify --json`), saved verbatim as real `warpline.impact_radius.v1`/`warpline.reverify_worklist.v1` envelopes. **(rank-6) Add a machine-readable provenance marker** to the golden, e.g. a top-level `"_provenance": {"source": "live-captured" | "pending-live-capture"}`, and a CI-visible test that **FAILS when `source == "pending-live-capture"` unless an explicit escape env var is set** — so "pending" can never silently masquerade as vendored (the discipline does not rely on prose). Name the var `LEGIS_WARPLINE_GOLDEN_PENDING_OK`; skeleton: `if golden.get("_provenance", {}).get("source") == "pending-live-capture" and not os.environ.get("LEGIS_WARPLINE_GOLDEN_PENDING_OK"): pytest.fail("golden is pending live capture — set LEGIS_WARPLINE_GOLDEN_PENDING_OK=1 only as a temporary escape")`. If no live warpline is reachable, construct from the §-spec, set `source: "pending-live-capture"`, record it in PROVENANCE.md, and let that CI assertion hold the line.
+
+**(rank-5) `_HostileWarpline` + the conformance stub** must both return a real envelope **with a GV-LG-3-VALID meta** (`local_only:true, peer_side_effects:[]`) — so the *advisory payload* (hostile values in `data.affected`/etc.), not a contract violation, is what's proven inert (a GV-LG-3-violating meta would now be REFUSED → `unavailable`, silently making the byte-identity test vacuous). Apply this to BOTH `test_warpline_advisory_boundary.py:74-81` AND `test_output_schema_conformance.py:635-639` (the latter asserts `status=='checked'`, so an invalid-meta stub would flip it to `unavailable`). **Add a guard** in the byte-identity test that the hostile-warpline side actually reached `status=='checked'` (not `'unavailable'`), so the comparison cannot silently degrade to `unavailable==unavailable` — concretely, in the test's governance-paths helper after the `policy_evaluate` calls: `pf = call_tool(runtime, "warpline_preflight_get", {...}); assert pf["structuredContent"]["status"] == "checked"`. **Add a positive test** that an invalid-meta envelope yields `unavailable` (pins GV-LG-3 end-to-end). **State explicitly** that the structural boundary test (`test_warpline_advisory_boundary.py:143-174`, derived from `_TOOL_HANDLERS`) is **preserved unchanged** — it is the load-bearing "warpline can't reach a verdict" invariant.
+
+**Rewrite PROVENANCE.md:** delete the "WARPLINE PRODUCER-SIDE OBLIGATION"; record that legis conforms to warpline's extant envelope (live-captured), cite SEAM 4 §4A + GV-LG-3. Remove the oracle's deleted-symbol imports (`HttpWarplineClient`, `_decode_json_response`). **Commit** in logical groups. **DoD:** no flat `{affected,count}`/`{entries,count}` anywhere; the oracle flows the golden through the real parser with hardcoded assertions (non-circular); golden carries a machine-checked provenance marker; conformance/boundary stubs carry a valid meta + the byte-identity guard + a GV-LG-3 positive test; the structural boundary test preserved; PROVENANCE reversed; committed.
+
+---
+
+### Task 5: full verification (suite + the 1.2.0 invariants + corrected coverage floors + gates)
+
+**Files:** `scripts/check_coverage_floors.py` (add a `src/legis/warpline_preflight/` floor, ~85%, once the new client lands — the package currently has **no** floor entry, so it's guarded only by the global 88%). Otherwise verification only.
+
+Run, expecting green:
+```bash
+uv run pytest tests/warpline_preflight tests/mcp -q
+uv run pytest tests/mcp/test_warpline_advisory_boundary.py -q # byte-identity + structural (143-174) invariants
+uv run pytest --cov=legis --cov-fail-under=88
+uv run python scripts/check_coverage_floors.py # NOTE actual floors: mcp.py 80, service/ 92 (the v1 plan's "~92/95" was wrong)
+uv run mypy src/legis
+uv run ruff check src
+uv run legis governance-gate
+uv run legis policy-boundary-check --root src --repo-root .
+uv run pytest tests/conformance/test_sei_oracle.py
+```
+**Watch:** the 1.2.0 advisory-boundary byte-identity + attestation forge-resistance invariants stay green. **Grep `src/ tests/`** for any lingering `WARPLINE_API_URL`, `/api/impact-radius`, `/api/reverify-worklist`, `"count"`, `"entries"`, `HttpWarplineClient`, or `_decode_json_response` in the warpline path — none should remain. **DoD:** all gates green; a `warpline_preflight/` coverage floor added; the 1.2.0 invariants hold; no HTTP/`count`/`entries`/deleted-symbol residue; branch ready for review (NOT merged — owner-gated).
+
+---
+
+## After execution — acceptance + closeout (product-owner, post-merge)
+- Close `legis-a53d92507d`. This is a **federation-seam-quality** bet, **NOT** a north-star (governance-honesty) item — it fails safe; do not move the north-star.
+- **Do NOT release 1.3.0 until this is merged** (the 1.3.0-prep carries the mis-frozen golden).
+- **reverify stays gated on wardline** (§2A names filigree). If drop: remove `reverify_worklist` from `service/preflight.py:28` + the client method (clean, independent). If bless: stays. Do not freeze it before wardline rules.
+
+## Revision history + validate-before-execution
+**Round 1 (7-agent + synthesizer, 2026-06-26): CHANGES_REQUESTED** — no cardinal-sin risk (advisory boundary, fail-open, GV-LG-3 all confirmed sound), but two verification-layer blockers, now fixed: **(blocker 1)** the oracle is re-specified NON-CIRCULAR (golden flows through `WarplineMcpClient._call` with hardcoded assertions, Layer-2 points at warpline's MCP contract fixture); **(blocker 2)** guardrail-(b) fail-safe is closed in CODE (non-dict-meta guard, the whole post-spawn parse wrapped → `WarplineError`, empty-argv rejected, `_read_jsonrpc_result` tightened) AND in TESTS (the `...` isError stub completed + non-dict-meta/scalar-result/non-JSON-line/empty-argv/timeout/oversize variants, all asserting `WarplineError` → `unavailable`). Folded in: the live-capture HARD DoD gate + Popen/SDK escalation branch (rank 3), the honest output-cap wording + `text=False` byte bound (rank 4), the conformance-stub valid-meta + byte-identity guard + GV-LG-3 positive test + structural-invariant statement (rank 5), the machine-readable golden provenance marker (rank 6), `stderr`/`returncode` in errors (rank 7), the `data.completeness` degraded-floor (rank 8), corrected coverage floors + a new `warpline_preflight` floor (rank 9), and the missed symbols + corrected PATH/threat-model wording (rank 10). **Re-run `/review-plan` (the ultracode review) on this revision before executing** — round 1's synthesis does not carry forward.
diff --git a/docs/plans/2026-06-27-governance-read-v1-per-sei.md b/docs/plans/2026-06-27-governance-read-v1-per-sei.md
new file mode 100644
index 0000000..fe18f58
--- /dev/null
+++ b/docs/plans/2026-06-27-governance-read-v1-per-sei.md
@@ -0,0 +1,499 @@
+# governance_read.v1 — per-SEI governance read (warpline→legis seam) Implementation Plan
+
+> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to execute this plan task-by-task.
+> **REVISION 2** — incorporates the 7-agent review (CHANGES_REQUESTED → addressed): the CLI
+> false-green (must-fix #1), the tautological oracle (#2), missing-DB downgrade (#3), schema
+> discriminator (#5), as_of guard (#6), MCP `_one_of` outputSchema (#7), durable artifacts (#8), and
+> all warnings. The contract (`contracts/governance_read.v1.schema.json` + the warpline prompt at
+> `docs/contracts/warpline-governance-read.v1-prompt.md`) is **already authored & on disk** (the
+> tightened, discriminated-union version) — Task 1 tests + commits it, it does not re-derive it.
+
+**Goal:** Expose legis's verified per-SEI governance clearances as the published `governance_read.v1`
+contract on CLI + MCP + HTTP, so warpline's `reverify_worklist(include_federation=True)` can enrich
+its worklist advisorily (never gate) with legis governance facts.
+
+**Architecture:** Project the EXISTING forge-proof per-SEI read (`read_sei_attestations`,
+`service/governance.py:223`) into a posture-record shape. One service projection
+(`read_governance_for_sei`) + a verified-gate wrapper (`read_governance_for_sei_gate`, mirroring
+`evaluate_override_rate_gate`) + a shared unavailable-envelope helper, all in `service/governance.py`.
+Three thin adapters; each owns its fail-closed "is the trail signature-verifiable?" pre-gate exactly
+as `attestation_get` does. The wire shape is FROZEN in `contracts/governance_read.v1.schema.json`.
+
+**Tech Stack:** Python 3.12, `uv`, FastAPI, JSON-RPC stdio, argparse, `jsonschema>=4.21`, pytest.
+
+**Prerequisites:**
+- `uv sync --dev` run.
+- Read before starting: `service/governance.py:223-350` (`read_sei_attestations`, the forge-proof
+ core) and `:381-409` (`evaluate_override_rate_gate`, the verify-gate pattern Task 2/5 mirror);
+ `mcp.py:324` (`_one_of`), `:2292-2333` (`_governance_trail_records`, `_tool_attestation_get`),
+ `:1268-1316` (the `attestation_get` `_one_of` outputSchema); `api/app.py:717-723` + `:865-871`;
+ `cli.py:263` (`_missing_sqlite_db`), `:287-329` (`_check_override_rate`); the FROZEN
+ `contracts/governance_read.v1.schema.json`; `tests/conformance/test_warpline_attestation_oracle.py`
+ (the frozen-golden pattern Task 3's oracle mirrors).
+
+---
+
+## Global Constraints (every task; the reviewer's attention lens — copy verbatim)
+
+1. **FAIL-CLOSED / no false-green (cardinal sin).** Unverifiable trail → `status:"unavailable"`
+ (discriminated, with reason), NEVER silent `records:[]`. A tampered trail (signature OR
+ hash-chain contiguity) RAISES loudly. `records:[]` is reachable ONLY under `status:"checked"`.
+ **Both verification halves are mandatory on every path: `store.verify_integrity()` (the chain /
+ delete-reorder-truncate defence) AND `TrailVerifier.verify()` (signatures).** `TrailVerifier.verify`
+ alone is NOT sufficient — it has no seq-contiguity walk (that lives only in `verify_integrity`).
+2. **Honest scope (cleared-only, v1).** `records:[]` = "no verified clearance for this SEI on the
+ verified trail" — NOT "ungoverned," NOT "unknown SEI." legis is an SEI consumer; it cannot and
+ must not pretend to distinguish unknown-from-ungoverned.
+3. **Forge-proofing inherited, not re-implemented.** `read_governance_for_sei` obtains records ONLY
+ by calling `read_sei_attestations`; no trail re-walk, no re-derived admission, no unsigned field.
+4. **`posture` claims only the PROVABLE mechanism.** `operator_override` → `"protected_override"`;
+ `signoff_cleared` → `"operator_signoff"`. Never an enforcement cell for a sign-off.
+5. **Record field is `disposition`, not `status`** (avoids the envelope-status / enrichment.governance
+ 3-way collision).
+6. **Service layer is single source of truth.** The projection, the verified-gate wrapper, and the
+ unavailable helper live in `service/governance.py`; adapters are thin; no governance decision
+ duplicated in an adapter (the CLI verify path goes through the service wrapper, NOT a hand-rolled
+ `TrailVerifier` call).
+7. **Guardrails (must not degrade):** advisory-boundary byte-identity; attestation forge-resistance
+ (reused, untouched); coverage floors via `scripts/check_coverage_floors.py` — actual floors are
+ **`src/legis/service/` = 92.0, `src/legis/mcp.py` = 80.0, `src/legis/api/` = 88.0** (global 88);
+ all CI gates green (pytest, mypy `src/legis`, ruff `E4/E7/E9/F`, SEI oracle, policy-boundary,
+ governance-gate).
+8. **No contract mutation post-commit, no touching** `audit_store.py`/`head_anchor.py`/`canonical.py`
+ (stays `ensure_ascii=False`). Signing keys out of reach. `.v1` is a one-way door — any change is
+ `.v2`, never an edit.
+9. Commit trailer `Co-Authored-By: Claude Opus 4.8 (1M context) `. Do NOT push
+ or open a PR (owner-gated). Implementers: commit ONLY your task's files; no history mutation.
+
+---
+
+### Task 1: Test + commit the FROZEN contract (`governance_read.v1.schema.json`)
+
+The schema and the warpline prompt are ALREADY on disk (authored as the contract freeze). This task
+adds the validation tests and commits all three together.
+
+**Files:**
+- Already on disk (commit, do not rewrite): `contracts/governance_read.v1.schema.json`,
+ `docs/contracts/warpline-governance-read.v1-prompt.md`
+- Create: `tests/contract/test_governance_read_v1_schema.py` (dir is `tests/contract`, NO `s`)
+- Modify: `pyproject.toml` dev group — add `"jsonschema[format]"` (enables the `date-time` format
+ assertion; `jsonschema` is already pinned, this adds the rfc3339 backend). Run `uv sync --dev`.
+
+**Step 1: Write the failing test** — load `contracts/governance_read.v1.schema.json`, build
+`Draft202012Validator(schema, format_checker=Draft202012Validator.FORMAT_CHECKER)`. Tests:
+- POSITIVE: checked+two-clearances validates; checked+empty-records validates; unavailable+reason
+ validates.
+- DISCRIMINATOR NEGATIVES (must-fix #5): `test_unavailable_without_reason_rejected`
+ (`{status:unavailable, records:[]}` no `unavailable` key → rejected); `test_unavailable_with_records_rejected`
+ (`{status:unavailable, records:[rec], unavailable:[…]}` → rejected); `test_checked_with_unavailable_key_rejected`.
+- CONSTRAINT NEGATIVES (warnings): `test_unknown_status_rejected`; `test_record_extra_field_rejected`;
+ `test_envelope_extra_field_rejected`; `test_empty_content_hash_rejected`; `test_empty_envelope_sei_rejected`;
+ `test_empty_record_sei_rejected`; `test_empty_reasons_rejected` (minItems:1); `test_invalid_posture_rejected`.
+- FORMAT (must-fix #6): `test_non_rfc3339_as_of_rejected` (`as_of:"not-a-date"` → rejected *because*
+ `format_checker` is wired; assert it passes WITHOUT the checker to prove the checker is load-bearing).
+- DRIFT GUARD (must-fix #8): `test_prompt_schema_block_matches_committed_file` — extract the JSON
+ fenced block under "## The contract" in `docs/contracts/warpline-governance-read.v1-prompt.md`,
+ `json.loads` both it and the committed schema file, assert the two parsed objects are EQUAL
+ (JSON-equal, not byte-equal — robust to whitespace).
+
+The reference shapes for these tests are the three literal samples in the warpline prompt + the
+ad-hoc bad shapes; reuse the validation matrix already proven in
+`scratchpad`-free form (the schema was validated at authoring — these tests pin it permanently).
+
+**Step 2: Run to verify failure** — `uv run pytest tests/contract/test_governance_read_v1_schema.py -v`
+(fails: test file absent / `jsonschema[format]` not synced).
+
+**Step 3:** No schema to write (frozen on disk). Add the dep, `uv sync --dev`, write the tests.
+
+**Step 4: Run to verify pass** — same command; all green. `uv run python -c "import json,jsonschema;
+jsonschema.Draft202012Validator.check_schema(json.load(open('contracts/governance_read.v1.schema.json')))"`.
+
+**Step 5: Commit** — schema + prompt + tests + pyproject change, one "freeze governance_read.v1
+contract" commit.
+
+**Definition of Done:**
+- [ ] All positive + 3 discriminator-negative + 8 constraint-negative + 1 format + 1 drift test pass.
+- [ ] `format_checker` is wired and proven load-bearing (the without-checker assertion).
+- [ ] Prompt's schema block parses EQUAL to the committed schema file.
+- [ ] Contract committed; `.v1` is now frozen (changes → `.v2`).
+
+---
+
+### Task 2: Service — projection + verified-gate wrapper + unavailable helper
+
+**Files:**
+- Modify: `src/legis/service/governance.py` (after `read_sei_attestations`, ~line 350)
+- Modify: `src/legis/service/__init__.py` (export `read_governance_for_sei`,
+ `read_governance_for_sei_gate`, `governance_read_unavailable`)
+- Test: `tests/service/test_governance_read.py`
+
+**Step 1: Write the failing test** — cover:
+- `test_projects_override_to_clearance_record`: a record admitted as `operator_override` →
+ `posture:"protected_override"`, full dict asserted. **Fixture uses `Verdict.OVERRIDDEN_BY_OPERATOR.value`,
+ not the literal string** (warning), with a comment naming the coupling.
+- `test_signoff_projection_full_dict` (monkeypatch `read_sei_attestations`): assert the WHOLE record
+ dict (sei/disposition/posture=`operator_signoff`/authority/as_of/reasons/content_hash), not just
+ posture (warning: expand the signoff test).
+- `test_verified_but_no_clearance_is_checked_empty` → `{status:checked, sei, records:[]}` (honest
+ absence, NOT unavailable).
+- `test_as_of_none_is_omitted` (must-fix #6): monkeypatch `read_sei_attestations` to return an
+ attestation with `recorded_at: None` → the record is OMITTED (empty records), not emitted with
+ `as_of: null`.
+- `test_as_of_non_rfc3339_is_omitted` (must-fix #6): `recorded_at:"garbage"` → omitted.
+- `test_unknown_kind_is_omitted` (warning): monkeypatch an attestation with `kind:"future_kind"` →
+ `_POSTURE_BY_KIND.get` returns None → omitted (WHEN IN DOUBT, OMIT), no KeyError crash.
+- `test_status_propagated_not_hardcoded`: monkeypatch `read_sei_attestations` to return
+ `status:"checked"` (current invariant) and assert the wrapper carries it; add an assert/guard so a
+ future non-`checked` status from `read_sei_attestations` does not get silently relabeled.
+- `test_gate_raises_on_signature_tamper` (must-fix #1): build a protected record set, corrupt a
+ signature, call `read_governance_for_sei_gate(records, sei, hmac_key=…, protected_policies=…)` →
+ raises `AuditIntegrityError`.
+- `test_gate_requires_key_for_protected`: protected records present, `hmac_key=None` → raises
+ `ProtectedKeyRequiredError`.
+- `test_unavailable_helper_shape`: `governance_read_unavailable(sei, "reason")` →
+ `{status:unavailable, sei, records:[], unavailable:[{reason}]}`.
+
+**Step 2: Run to verify failure** (`ImportError`).
+
+**Step 3: Implement** in `service/governance.py`:
+
+```python
+from datetime import datetime
+
+_POSTURE_BY_KIND = {
+ "operator_override": "protected_override", # provable: protected_cell + OVERRIDDEN_BY_OPERATOR signed
+ "signoff_cleared": "operator_signoff", # provable: SIGNED_OFF + integrity-bound request
+}
+# Three coupled points: read_sei_attestations' admitted kinds, these map keys, and the schema's
+# `posture` enum. A new clearance kind must update all three. reasons = clearance-kind code (WHAT
+# happened); posture = provable mechanism (HOW) — distinct axes, 1:1 in v1.
+
+
+def _is_rfc3339(value: Any) -> bool:
+ if not isinstance(value, str):
+ return False
+ try:
+ datetime.fromisoformat(value.replace("Z", "+00:00"))
+ return True
+ except ValueError:
+ return False
+
+
+def read_governance_for_sei(verified_runtime_records: list, sei: str) -> dict[str, Any]:
+ """Per-SEI VERIFIED GOVERNANCE CLEARANCES as the ``governance_read.v1`` envelope.
+
+ A pure PROJECTION of ``read_sei_attestations`` (the forge-proof admitted set) — adds NO admission
+ logic and reads NO unsigned field, inheriting the signature-coverage/asymmetric-error guarantees.
+ A clearance whose ``recorded_at`` is absent or non-RFC3339, or whose ``kind`` has no known
+ posture, is OMITTED (asymmetric-error: a missing clearance only wastes warpline work; a malformed
+ one is the unsafe direction). The caller owns the ``status:"unavailable"`` pre-gate via
+ ``governance_read_unavailable`` for the no-key / unverifiable-trail case.
+ """
+ att = read_sei_attestations(verified_runtime_records, sei)
+ if att.get("status") != "checked":
+ # read_sei_attestations is contracted to return "checked" here (the handler owns the
+ # unavailable pre-gate). If that ever changes, fail loud rather than relabel it "checked".
+ raise AuditIntegrityError(
+ f"read_sei_attestations returned unexpected status {att.get('status')!r}"
+ )
+ records: list[dict[str, Any]] = []
+ for a in att["attestations"]:
+ posture = _POSTURE_BY_KIND.get(a["kind"])
+ if posture is None:
+ continue # unknown kind -> omit, never fabricate a posture
+ if not _is_rfc3339(a.get("recorded_at")):
+ continue # missing / malformed timestamp -> omit, never ship as_of:null
+ records.append(
+ {
+ "sei": sei,
+ "disposition": "cleared",
+ "posture": posture,
+ "authority": "operator",
+ "as_of": a["recorded_at"],
+ "reasons": [a["kind"]],
+ "content_hash": a["content_hash"],
+ }
+ )
+ return {"status": "checked", "sei": sei, "records": records}
+
+
+def read_governance_for_sei_gate(
+ records: list, sei: str, *, hmac_key: str | None, protected_policies
+) -> dict[str, Any]:
+ """Verified governance read for the CLI/batch path: detect protected -> require key -> verify
+ signatures (fail closed) -> project. Mirrors ``evaluate_override_rate_gate`` exactly, so the CLI
+ measures the same trust the HTTP/MCP paths do (Constraint 6). The store-level hash-chain check
+ (``verify_integrity``) is the CALLER's responsibility BEFORE this call (as in
+ ``_check_override_rate``) — both halves are mandatory (Constraint 1).
+ """
+ protected_present = any(
+ _requires_protected_verification(r.payload, protected_policies) for r in records
+ )
+ if protected_present and not hmac_key:
+ raise ProtectedKeyRequiredError(
+ "Protected audit records require LEGIS_HMAC_KEY for verification"
+ )
+ if hmac_key:
+ verifier = TrailVerifier(hmac_key.encode("utf-8"), protected_policies)
+ try:
+ verifier.verify(records)
+ except TamperError as exc:
+ raise AuditIntegrityError(
+ f"Protected audit trail verification failed: {exc}"
+ ) from exc
+ return read_governance_for_sei(records, sei)
+
+
+def governance_read_unavailable(sei: str, reason: str) -> dict[str, Any]:
+ """The shared ``governance_read.v1`` unavailable envelope (one shape across all 3 adapters).
+ NEVER a silent ``checked``/``[]`` — an unverifiable trail reads as "could not check" (GOV-2)."""
+ return {"status": "unavailable", "sei": sei, "records": [], "unavailable": [{"reason": reason}]}
+```
+
+(`TamperError` is already imported in `governance.py` for `evaluate_override_rate_gate`; confirm and
+reuse the same import. `AuditIntegrityError`, `ProtectedKeyRequiredError`,
+`_requires_protected_verification`, `TrailVerifier` likewise.)
+
+**Step 4: Run to verify pass**; then `uv run mypy src/legis`.
+
+**Step 5: Commit.**
+
+**Definition of Done:**
+- [ ] All ~10 tests pass; mypy clean.
+- [ ] Projection calls `read_sei_attestations`; omits unknown-kind / bad-`as_of`; propagates/guards
+ status; reads no unsigned field.
+- [ ] `read_governance_for_sei_gate` mirrors `evaluate_override_rate_gate` (key-required + signature
+ verify, `AuditIntegrityError` on tamper); three names exported.
+
+---
+
+### Task 3: MCP tool `governance_read` (handler + `_one_of` outputSchema + frozen-golden oracle)
+
+**Files:**
+- Modify: `src/legis/mcp.py` — name list (~106); tool def w/ `_one_of` outputSchema (near the
+ `attestation_get` def ~1268); handler (near `_tool_attestation_get` ~2308); `_TOOL_HANDLERS`
+ registry (~2568, the dict is `_TOOL_HANDLERS`, not `_TOOLS`).
+- Test: `tests/mcp/test_governance_read_tool.py`
+- Modify: `tests/mcp/test_output_schema_conformance.py` (per-tool tests — add explicit cases for the
+ new tool's BOTH variants; this file is NOT auto-iterating).
+- Create: `tests/conformance/test_governance_read_oracle.py` (FROZEN GOLDEN — mirror
+ `test_warpline_attestation_oracle.py`)
+- Create (committed by the oracle on first run): `tests/conformance/fixtures/governance_read_*.json`
+
+**Step 1: Write the failing tests:**
+- Handler tests: (a) verifiable trail with a clearance → `{status:checked, records:[…]}`;
+ (b) `protected_gate is None or trail_verifier is None` → `{status:unavailable, …}` (NOT empty
+ checked); (c) tampered trail → `AuditIntegrityError` → `AUDIT_INTEGRITY_FAILURE` error envelope.
+ Reuse runtime fixtures from `tests/mcp/test_attestation*.py`.
+- outputSchema conformance: add two explicit cases in `test_output_schema_conformance.py` — the
+ `checked` variant and the `unavailable` variant of `governance_read` each validate against the
+ declared `_one_of` outputSchema.
+- **Frozen-golden oracle** (must-fix #2): mirror `test_warpline_attestation_oracle.py` exactly —
+ `_build_attested_store` (reuse the attestation oracle's helper) to write GENUINE signed records for
+ BOTH clearance kinds; `call_tool(runtime, "governance_read", {"sei": _SEI})`; write
+ `structuredContent` to a committed fixture; pin its git blob SHA1 as `GOLDEN_BLOB_SHA`; the test
+ asserts LIVE output == FROZEN golden by VALUE (not `schema.validate(live)`). Add
+ `test_both_clearance_kinds_are_pinned`. The golden is a projection of the existing attestation
+ golden — cross-check the content_hashes match.
+
+**Step 2: Run to verify failure** (unknown tool / missing fixture).
+
+**Step 3: Implement** — handler mirrors `_tool_attestation_get`:
+
+```python
+def _tool_governance_read(runtime: McpRuntime, args: dict[str, Any]) -> dict[str, Any]:
+ from legis.service.governance import governance_read_unavailable, read_governance_for_sei
+
+ sei = _require(args, "sei")
+ # FAIL-CLOSED pre-gate (same invariant as attestation_get): a verifiable answer needs BOTH a
+ # protected gate AND a trail_verifier. Missing either -> unavailable (discriminated), never [].
+ if runtime.protected_gate is None or runtime.trail_verifier is None:
+ return _tool_result(
+ governance_read_unavailable(
+ sei, "trail not signature-verifiable (no protected gate / verifier)"
+ )
+ )
+ # _governance_trail_records -> verified_records runs BOTH verify_integrity (chain) and
+ # TrailVerifier.verify (signatures), raising AuditIntegrityError (-> AUDIT_INTEGRITY_FAILURE) on
+ # a tampered protected trail (loud, never a result).
+ return _tool_result(read_governance_for_sei(_governance_trail_records(runtime), sei))
+```
+
+Tool def: inputSchema `{sei: string, required}`; **outputSchema built with `_one_of([checked_variant,
+unavailable_variant])` using the v1 `clearance_record` fields** (NOT attestation_get's fields,
+must-fix #7) — `checked_variant`: `status:const checked`, `records` array of the 7-field record;
+`unavailable_variant`: `status:const unavailable`, `records` maxItems 0, `unavailable` array of
+`{reason}`. Description: "Per-SEI VERIFIED governance CLEARANCE facts (governance_read.v1).
+Advisory — never gate on this. records:[] under 'checked' = no verified clearance, NOT ungoverned;
+'unavailable' ≠ 'absent'." Register in the name list + `_TOOL_HANDLERS`.
+
+**Step 4: Run to verify pass** — the tool tests + the oracle + `test_output_schema_conformance.py`
+(and any meta-test that every tool has an outputSchema).
+
+**Step 5: Commit** (incl. the committed golden fixture).
+
+**Definition of Done:**
+- [ ] Tool registered (name list, def w/ `_one_of` outputSchema over v1 fields, `_TOOL_HANDLERS`).
+- [ ] Unavailable pre-gate gates on BOTH objects; tamper → `AUDIT_INTEGRITY_FAILURE`.
+- [ ] Oracle is a FROZEN GOLDEN with a pinned blob SHA, asserting LIVE == FROZEN (not schema-validate);
+ both clearance kinds pinned.
+
+---
+
+### Task 4: HTTP route `GET /governance/sei/{sei:path}/governance-read`
+
+**Files:**
+- Modify: `src/legis/api/app.py` (route near `identity_gaps`/`lineage_integrity` ~865; import the two
+ service fns with the other service imports)
+- Test: `tests/api/test_governance_read_route.py`
+
+**Step 1: Write the failing test** — TestClient: (a) wired gate+verifier + a clearance → 200
+`{status:checked, records:[…]}`; (b) no gate/verifier → 200 `{status:unavailable, …}`; (c) tampered
+trail → 500. Add (d) a SEI containing `/` (e.g. URL-encoded) round-trips to the handler (the
+`{sei:path}` capture, warning). Mirror the identity-gap/lineage app-construction fixtures.
+
+**Step 2: Run to verify failure** (404).
+
+**Step 3: Implement** inside `create_app`:
+
+```python
+ @app.get("/governance/sei/{sei:path}/governance-read")
+ def governance_read(sei: str) -> dict:
+ # {sei:path} captures a SEI that may contain '/'. FAIL-CLOSED pre-gate mirrors the MCP tool;
+ # verified_governance_records() runs BOTH chain + signature checks and maps tamper to HTTP 500.
+ if protected_gate is None or trail_verifier is None:
+ return _governance_read_unavailable(
+ sei, "trail not signature-verifiable (no protected gate / verifier)"
+ )
+ return _read_governance_for_sei(verified_governance_records(), sei)
+```
+
+Import `read_governance_for_sei as _read_governance_for_sei`, `governance_read_unavailable as
+_governance_read_unavailable`.
+
+**Step 4: Run to verify pass.**
+
+**Step 5: Commit.**
+
+**Definition of Done:**
+- [ ] Route returns the v1 envelope; unavailable w/o gate/verifier; 500 on tamper; `{sei:path}`
+ handles a `/`-bearing SEI.
+
+---
+
+### Task 5: CLI `legis governance-read ` (verified path through the service gate)
+
+**Files:**
+- Modify: `src/legis/cli.py` — subparser near `governance-gate` (~109); dispatch near ~741; the
+ `_governance_read(db_url, sei)` helper near `_check_override_rate` (~292)
+- Test: `tests/cli/test_governance_read_cli.py`
+
+**Step 1: Write the failing test** (mirror existing CLI test invocation):
+- (a) `LEGIS_HMAC_KEY` set + verifiable store w/ a clearance → exit 0, stdout JSON `{status:checked,
+ records:[…]}`.
+- (b) NO `LEGIS_HMAC_KEY` → exit 0, stdout `{status:unavailable,…}` (can't verify sigs).
+- (c) **CHAIN-tampered store** (delete/reorder a non-protected record — NOT just a bad signature,
+ must-fix #1) → nonzero exit AND stderr contains "audit integrity". This is the false-green
+ regression test; it MUST exercise `verify_integrity`, which `TrailVerifier.verify` alone would miss.
+- (d) signature-tampered protected store → nonzero exit + "audit integrity" on stderr.
+- (e) **missing/relocated DB** (point `--db` at a nonexistent path, must-fix #3) → stdout
+ `{status:unavailable,…}` ("governance store not found"), NOT a silent `checked/[]` from an
+ auto-created empty DB.
+
+**Step 2: Run to verify failure.**
+
+**Step 3: Implement** — subparser `governance-read` with positional `sei` and `--db`
+(default `governance_db_url()`); **no `--json` flag** (output is always JSON; warning). Dispatch
+branch `if args.command == "governance-read": return _governance_read(args.db, args.sei)`. Helper:
+
+```python
+def _governance_read(db_url: str, sei: str) -> int:
+ import os
+ from legis.config import protected_policies
+ from legis.service.errors import AuditIntegrityError, ProtectedKeyRequiredError
+ from legis.service.governance import governance_read_unavailable, read_governance_for_sei_gate
+ from legis.store.audit_store import AuditStore
+
+ hmac_key = os.environ.get("LEGIS_HMAC_KEY")
+ if not hmac_key:
+ # No key -> signatures unverifiable from the CLI -> unavailable, never silent checked/[].
+ print(json.dumps(governance_read_unavailable(
+ sei, "trail not signature-verifiable (LEGIS_HMAC_KEY unset)"), sort_keys=True))
+ return 0
+ missing_db = _missing_sqlite_db(db_url)
+ if missing_db is not None:
+ # Absent store on a READ = unavailable (NOT the override-rate CI PASS_WITH_NOTICE axis, and
+ # NOT an auto-created empty DB read as checked/[]).
+ print(json.dumps(governance_read_unavailable(
+ sei, f"governance store not found: {missing_db}"), sort_keys=True))
+ return 0
+ store = AuditStore(db_url)
+ if not store.verify_integrity(): # the chain / delete-reorder-truncate half (mandatory)
+ print("Error: audit integrity failure: database hash chain verification failed", file=sys.stderr)
+ return 1
+ records = store.read_all()
+ try: # the signature half, through the service gate (Constraint 6)
+ envelope = read_governance_for_sei_gate(
+ records, sei, hmac_key=hmac_key, protected_policies=protected_policies())
+ except (ProtectedKeyRequiredError, AuditIntegrityError) as exc:
+ print(f"Error: {exc}", file=sys.stderr)
+ return 1
+ print(json.dumps(envelope, sort_keys=True))
+ return 0
+```
+
+**Step 4: Run to verify pass** — especially the chain-tamper (c) and missing-DB (e) cases.
+
+**Step 5: Commit.**
+
+**Definition of Done:**
+- [ ] Both verify halves run: `store.verify_integrity()` (chain) BEFORE `read_all`, then the service
+ gate (signatures). Chain-tamper test (c) is RED without `verify_integrity`, GREEN with it.
+- [ ] No key → unavailable; missing DB → unavailable; tamper → nonzero + "audit integrity" stderr.
+- [ ] Exceptions caught are `ProtectedKeyRequiredError`/`AuditIntegrityError` (TamperError is wrapped
+ inside the service gate) — no bare `except`.
+
+---
+
+### Task 6: Guardrails — advisory-boundary pin, coverage floors, full gate sweep
+
+**Files:** `tests/mcp/test_warpline_advisory_boundary.py` (one edit); otherwise verification.
+
+**Steps:**
+1. **Advisory-boundary structural pin** (warning): add `read_governance_for_sei`,
+ `read_governance_for_sei_gate`, and `governance_read_unavailable` to the explicit symbol list in
+ `test_runtime_warpline_referenced_in_no_verdict_path_function`
+ (`tests/mcp/test_warpline_advisory_boundary.py:~218-235`), so the new service fns are pinned as
+ non-verdict-path. Run that file + the byte-identity advisory test — both green.
+2. **Forge-resistance untouched:** `uv run pytest -k "attestation"` green.
+3. **Coverage floors** (corrected numbers): `uv run pytest --cov=legis --cov-fail-under=88` then
+ `uv run python scripts/check_coverage_floors.py` — `service/` ≥92, `mcp.py` ≥80, `api/` ≥88. Add a
+ missing unavailable/tamper-branch test if a floor dips.
+4. **Full CI-equivalent sweep:** `uv run ruff check src`, `uv run mypy src/legis`,
+ `uv run pytest tests/conformance/test_sei_oracle.py`,
+ `uv run legis policy-boundary-check --root src --repo-root .`, `uv run legis governance-gate`.
+5. **Cross-transport schema agreement:** one captured MCP result (the Task 3 golden), one HTTP body,
+ and one CLI stdout each validate against `contracts/governance_read.v1.schema.json` (with the
+ format checker).
+
+**Definition of Done:**
+- [ ] Advisory-boundary tests green; the three new service fns pinned in the structural test.
+- [ ] All coverage floors hold (corrected: service 92 / mcp 80 / api 88); all CI gates green locally.
+- [ ] One captured output per transport validates against the frozen v1 schema.
+
+---
+
+## Execution notes for the controller
+
+- **Scope gate (review must-fix #4):** the owner directed parallel execution (warpline is building
+ its side now), so cleared-only v1 proceeds. It is a SAFE SUBSET — a future `governance_read.v2`
+ that ADDS dispositions/kinds leaves every v1 record valid. The cleared-only scope is flagged to
+ warpline in the prompt for pre-finalize confirmation; do NOT expand scope here. If warpline needs
+ in-flight governance, that is a `.v2` + a new plan, never a v1 edit.
+- **Schema amendment (must-fix #5):** already applied — the committed schema enforces the
+ discriminated union, and the warpline prompt carries the same tightened bytes + a
+ backward-compatible note. No further coordination needed unless warpline reports a consumer break
+ (it should not — the tightening only constrains legis's own output).
+- Order is strict: Task 1 (contract) → Task 2 (service) → Tasks 3/4/5 (adapters, each depends on
+ Task 2) → Task 6. Within each task: TDD (red → green → commit).
+- Local merge only; do NOT push or open a PR (owner-gated, like the prior three findings).
diff --git a/docs/product/current-state.md b/docs/product/current-state.md
index 869630a..0d25529 100644
--- a/docs/product/current-state.md
+++ b/docs/product/current-state.md
@@ -1,22 +1,26 @@
-# Current State — Legis Checkpoint: 2026-06-25 (2nd) · committed (PDR-0004)
+# Current State — Legis Checkpoint: 2026-06-28 · committed (PDR-0007, PDR-0008)
## The bet right now
-**Keep the governance-honesty surface true post-gold** (north-star: open governance-honesty defects → 0, currently **3**) — close the three confirmed P2 findings. The **Warpline federation seam is COMPLETE** (Tasks 1–8 + spec fix), held on merge by the owner until warpline's own body of work lands.
+**Keep the governance-honesty surface true post-gold** (north-star: open governance-honesty defects → **0** by 2026-07-15; currently **1**). The last confirmed P2 finding is **legis-0186c23a2c** (policy_boundary_check accepts roots outside the source root) — unplanned; closing it takes the north-star to 0. This session shipped two **federation seams** (seam-quality, NOT north-star items) and deployed the governance_read surface locally.
-## In flight
-- **Warpline interfaces** (legis-1734128d34) — **COMPLETE** on branch `warpline-interfaces` (`5a30cd8..1e21418`): advisory preflight consumer + `attestation_get` with the **forge-proof per-SEI attestation classifier** (Task 8, both kinds shipped) + byte-identical advisory-boundary spine. All CI gates green (pytest 1237, mypy clean, coverage 92.13%, ruff). **Held on merge** (owner: warpline mid-work, will push when done). Adversarial forge phase: zero forges admitted.
-- **Governance-honesty P2 findings** — confirmed, ready, unclaimed: unverified posture tail (legis-476ab6f125); protected batch without HeadAnchor advance (legis-0c310712a7); policy_boundary_check accepts roots outside source root (legis-0186c23a2c). The north-star Now bet; untouched.
+## In flight / not yet started
+- **legis-0186c23a2c** (unbounded policy-boundary root) — the **last** north-star P2 finding; confirmed, unplanned. Closing it → north-star **0**. **Next session's primary candidate.**
+- **G1 integration tail** (legis-9a47068338) — legis-side DONE; **warpline must wire `LegisGovernanceClient` + restart its MCP connection** to flip its legis member `disabled`→`clean`. Awaiting warpline's live confirmation (not legis-blocked).
-## Open questions / blocked-on-owner
-- **Merge / publish** `warpline-interfaces` — held by owner (warpline's body of work mid-flight; they push when done). This is the only remaining gate; nothing else blocks.
-- **Warpline wire format** (§6) — inferred/TO-CONFIRM; ships shape-validating, degrades to `unavailable`. Gates real integration (confirmed when warpline lands), not unit work.
-- **Inferred vision/metrics** — PDR-0001's reversal trigger fires on the owner's first review (the `(set)` time-to-close TARGET still needs an owner number).
-- *(Resolved this session: Task 8 ratification → done (PDR-0004); spec §4.1 correction → done.)*
+## Recently shipped this session (LOCAL main; NOT pushed)
+- **`governance_read.v1`** (PDR-0007, legis-9a47068338) — per-SEI governance read legis publishes for warpline; cleared-only; CLI+MCP+HTTP + frozen discriminated-union contract; verified (1335 passed, **mutation-proven** false-green-free) + deployed to the global `legis` tool (1.3.0). Integration pending warpline.
+- **Plainweave preflight consumer** (PDR-0008, parallel session `27f12da`) — legis reads Plainweave's `preflight_facts.v1` advisory/enrich-only; 1377 passed at HEAD.
+- Follow-ups filed: **legis-a0e286f5aa** (MCP outputSchema looser than the frozen contract — Minor).
-## Last checkpoint did
-- **Ratified + implemented Task 8** — the forge-proof attestation classifier (both `operator_override` + `signoff_cleared`), via a ground→implement→adversarial-forge workflow; zero forges admitted, both positives admit (PDR-0004).
-- Corrected design spec §4.1/§4.2/§7 (false unconditional fail-closed claim → conditional; confirmed discriminator).
-- Independently re-ran the full CI gate (pytest 1237, mypy clean, coverage 92.13%, ruff) — green.
+## Open questions / blocked-on-owner (escalations)
+- **⚠ Release (push + publish) — owner-gated; the one escalation this session.** Local `main` (HEAD `27f12da`) is now far ahead of origin: the 1.3.0 line + 3 prior fixes + warpline-preflight + **G1 governance_read.v1** + **Plainweave consumer**. Direct push is **ruleset-blocked** (PR required). Decide: **(a) SCOPE** — what ships next (fold G1 + Plainweave into 1.3.0, or cut a 1.4.0)? **(b) MECHANISM** — shall I push a branch + open the PR, and who cuts the GitHub Release / PyPI publish? **Nothing is pushed; `governance_read.v1` stays a LOCAL freeze until this lands** (keeps the v1 contract revisable if warpline needs a change before publish).
+- **warpline handshake — live confirmation pending** (the G1 integration half; sibling-side).
+- **Plainweave live e2e capture** — the parallel session's golden is CONSTRUCTED, not live-captured (hub MCP misroutes Plainweave); a flagged follow-up.
-## Next session, start here
-Either the owner's merge sign-off when warpline's work lands (the branch is complete and green), **or** pick up the north-star Now bet — the three P2 governance-honesty findings (legis-476ab6f125, -0c310712a7, -0186c23a2c), still unclaimed.
+## What this checkpoint did
+- Recorded **PDR-0007** (build `governance_read.v1` — cleared-only per-SEI federation read, owner-directed) and **PDR-0008** (record the parallel session's Plainweave advisory consumer under [[0003-federation-read-doctrine]]/[[0006-warpline-preflight-conform-to-extant-mcp-envelope]] doctrine).
+- Refreshed `metrics.md` (north-star unchanged at 1; advisory boundary re-proven ×2; the new federation surface **mutation-proven** false-green-free; CI green, 1377 passed @ HEAD) and `roadmap.md` (two seams → recently-shipped).
+- Reconciled the tracker: filed **legis-9a47068338** (G1 feature) + **legis-a0e286f5aa** (outputSchema follow-up).
+
+## Next session starts here
+**Plan legis-0186c23a2c** (the last north-star finding) via the `/axiom-planning` → review → execute path — closing it takes the north-star to **0**. OR, if the owner is ready: the **release decision** (scope + PR mechanism) for the now-substantial unpushed local `main`. The warpline + Plainweave integration tails are sibling-side, not legis-blocked.
diff --git a/docs/product/decisions/0005-accept-governance-honesty-bet-partial.md b/docs/product/decisions/0005-accept-governance-honesty-bet-partial.md
new file mode 100644
index 0000000..edb7623
--- /dev/null
+++ b/docs/product/decisions/0005-accept-governance-honesty-bet-partial.md
@@ -0,0 +1,27 @@
+# PDR-0005 — Accept the governance-honesty bet (partial): 2 of 3 P2 findings closed; north-star 3 → 1
+
+Date: 2026-06-27 Status: accepted (within grant; merges local-only, push/publish owner-gated) Author: claude (opus, product-owner)
+Supersedes: — Related: PRD-0005; tracker legis-476ab6f125, legis-0c310712a7, legis-0186c23a2c; plans `docs/plans/2026-06-26-posture-read-floor-verify-integrity.md`, `docs/plans/2026-06-26-protected-batch-headanchor-transaction.md`
+
+## Context
+PRD-0005 (the Now bet) is the north-star: open governance-honesty defects → 0 by 2026-07-15, over three codex-confirmed P2 findings. This session executed the first two against a governance-honesty surface whose cardinal sin is a false-green.
+
+## Options considered
+1. Plan → execute each finding directly (single-pass).
+2. Plan → multi-agent adversarial review → revise → re-review → subagent-driven TDD (fresh implementer + spec/quality review per task) → final whole-branch review → accept. Higher assurance.
+3. Defer to a later session.
+
+## The call
+Option 2, for both findings.
+- **legis-476ab6f125** (unverified posture tail): `read_floor()` now gates on `verify_integrity()` and fails closed to `None`→`structured`; the recomputed-chain forgery is pinned as the conceded raw-file-write residual (README.md:137), not implied-closed. **Closed @ main eb28e4b.** The round-1 review caught two real defects in the plan before any code: a test the gate breaks (whose mis-prescribed remedy could have nudged an implementer to *weaken the gate*), and a false "doctor backstops this residual" docstring claim. Both fixed pre-execution.
+- **legis-0c310712a7** (un-anchored protected batch): `ProtectedGate.transaction()` (a verbatim `SignoffGate.transaction()` mirror) advances the HeadAnchor after commit; doubly-latent/preventive (production wires `anchor=None` and no caller batches protected appends today). **Closed @ main 79b4008.** Review APPROVED_WITH_WARNINGS; clean execution.
+
+North-star **3 → 1**. legis-0186c23a2c (unbounded policy-boundary root) remains. Two P3 follow-ups filed so the closures are honest, not net-zero: legis-fcd59caa67 (ungated sibling reads `epoch_reset_unacknowledged` et al.) and legis-dfdeade118 (doctor `None`-cause diagnostic).
+
+## Rationale
+The adversarial review caught two false-green-adjacent defects on read_floor *before code was written* — exactly the failure mode the honesty surface exists to prevent — validating the heavier method for this bet. Both fixes preserve fail-closed and pass every CI gate (mypy/ruff/coverage floors/governance-gate/policy-boundary/SEI oracle). Median time-to-close = 6 days (within the owner-set ≤14).
+
+## Reversal trigger
+- If legis-476ab6f125 or legis-0c310712a7 **reopens** (a regression or a missed bypass) → reopen + a PDR-0001 reversal-trigger review.
+- If **2026-07-15 passes with legis-0186c23a2c still open** → the north-star date target is missed → PDR-0001 reversal review (re-date or re-scope).
+- If a **new confirmed governance-honesty bypass lands before 2026-07-15** → the north-star denominator grows → re-assess the date (PRD-0005 open-question).
diff --git a/docs/product/decisions/0006-warpline-preflight-conform-to-extant-mcp-envelope.md b/docs/product/decisions/0006-warpline-preflight-conform-to-extant-mcp-envelope.md
new file mode 100644
index 0000000..96d1aaa
--- /dev/null
+++ b/docs/product/decisions/0006-warpline-preflight-conform-to-extant-mcp-envelope.md
@@ -0,0 +1,23 @@
+# PDR-0006 — Warpline preflight: conform to warpline's extant MCP envelope (transport = MCP); reverse the producer-obligation; reverify kept droppable
+
+Date: 2026-06-27 Status: accepted (transport owner-confirmed 2026-06-26; merge local-only, push/1.3.0 publish owner-gated) Author: claude (opus, product-owner)
+Supersedes: — Related: tracker legis-a53d92507d; plan `docs/plans/2026-06-26-warpline-preflight-mcp-transport.md`; hub interface-lock SEAM 4 §4A / GV-LG-1 / GV-LG-3; [[0003-federation-read-doctrine]]
+
+## Context
+The warpline maintainer found that legis's warpline preflight client spoke a **phantom HTTP wire** (`GET /api/impact-radius`) warpline never served — it had been copied from legis's loomweave HTTP-client pattern; warpline is MCP/CLI-only. Worse, a 1.3.0-prep commit (6f50a33, on local main) **froze the wrong flat shape** as a golden conformance vector and recorded a "WARPLINE PRODUCER-SIDE OBLIGATION" demanding warpline build an HTTP producer to match legis's flat assumption — the inverse of the real contract. The seam failed safe (→ `unavailable`) but was dead-on-arrival, and the golden asserted a wire warpline doesn't serve.
+
+## Options considered
+1. **Keep the flat HTTP shape; warpline ships an HTTP producer** (last night's producer-obligation). REJECTED — forces a sibling to reimplement something already extant; inverts SEAM 4 §4A (which pins the seam to `warpline_impact_radius_get` — the envelope) and GV-LG-3 (legis reads `meta.local_only`/`peer_side_effects` off the envelope; the flat shape has no `meta`).
+2. **Transport = CLI subprocess** (`warpline … --json`). Workable, but the CLI verbs lack `--rev-range`, so legis must reconstruct the rev_range→affected resolution via a two-step — and the friction tempts a "warpline, add --rev-range" ask, a softer version of the same mistake.
+3. **Transport = MCP stdio** — consume the EXTANT `warpline_impact_radius_get` / `warpline_reverify_worklist_get` tools with `rev_range`; one call per verb; the seam-lock literally references the MCP tool.
+
+## The call
+**Option 3** — owner-confirmed 2026-06-26, *reversing an initial CLI pick* after the owner's challenge ("didn't warpline tell us the standard? aren't we forcing them to reimplement something already extant?"). A stdlib stdio JSON-RPC client (no new dependency) over an injectable `invoke` seam; parses the real `warpline.impact_radius.v1`/`.reverify_worklist.v1` envelope (pass-through), enforces GV-LG-3 `meta` + `data.completeness`, fails closed on every fault; **real live-captured fixtures** (non-circular oracle, committed session transcripts); the producer-obligation **reversed**. Shipped to local main @ **075edd0**. Validated by a 7-agent review (round 1 CHANGES_REQUESTED — 2 verification-layer blockers; round 2 APPROVED_WITH_WARNINGS) + a final opus whole-branch review. The live-capture DoD gate caught warpline's *undocumented required* `repo` argument.
+
+## Rationale
+Legis conforms to what warpline already serves; warpline reimplements nothing. This is the durable federation principle, a sibling of [[0003-federation-read-doctrine]]'s facts-not-verdicts: **consume the sibling's extant standard; never force a sibling to reshape to a consumer's convenience.** The advisory boundary is preserved (byte-identity + the derived structural test); this is a **federation-seam-quality** bet, NOT a north-star governance-honesty item (it fails safe — governance is byte-identical with/without warpline).
+
+## Reversal trigger
+- If **wardline rules (SEAM 4 §2A)** that legis must not consume `reverify_worklist` (filigree is the lock's named reverify consumer) → **drop the reverify half** (the client method + `service/preflight.py:28`); the client is structured for a clean removal. If wardline blesses it as a new seam → a new PDR records the sanctioned dependency.
+- If the **advisory-boundary byte-identity invariant** ever fails (a governance verdict diverges with warpline present vs absent) → reopen immediately (the guardrail).
+- If warpline's **MCP surface changes** (envelope schema / required args) → the client's parse/args layer reopens (it degrades to `unavailable` until then).
diff --git a/docs/product/decisions/0007-governance-read-v1-per-sei-federation-seam.md b/docs/product/decisions/0007-governance-read-v1-per-sei-federation-seam.md
new file mode 100644
index 0000000..e86281b
--- /dev/null
+++ b/docs/product/decisions/0007-governance-read-v1-per-sei-federation-seam.md
@@ -0,0 +1,27 @@
+# PDR-0007 — Build `governance_read.v1`: a per-SEI governance read legis publishes for warpline (cleared-only v1)
+
+Date: 2026-06-27 Status: accepted (owner-directed: build, parallel-execute with warpline, deploy locally; PUBLISH/push owner-gated) Author: claude (opus, product-owner)
+Supersedes: — Related: tracker legis-a0e286f5aa (follow-up); plan `docs/plans/2026-06-27-governance-read-v1-per-sei.md`; contract `contracts/governance_read.v1.schema.json`; warpline prompt `docs/contracts/warpline-governance-read.v1-prompt.md`; hub SEAM 4 / GV-LG-1; [[0003-federation-read-doctrine]]; [[0004-ratify-implement-forge-proof-attestation-classifier]]; [[0006-warpline-preflight-conform-to-extant-mcp-envelope]]
+
+## Context
+The federation maintainer asked for a per-SEI governance read so warpline's `reverify_worklist(include_federation=True)` can enrich its worklist advisorily with legis governance facts (warpline→legis direction — the inverse of the PDR-0006 warpline-preflight seam). Legis is the governance authority; warpline echoes its facts as `enrichment.governance: present|absent|unavailable` and **never gates** on them. The closest existing surface is `read_sei_attestations` (the forge-proof per-SEI read behind MCP `attestation_get`, PDR-0004); legis had no *published contract artifact* and no CLI/HTTP exposure of a per-SEI governance read.
+
+## Options considered
+1. **Greenfield governance read, new shape.** Rejected — legis already holds a forge-proof per-SEI read; a parallel implementation duplicates admission logic and adds a new forge surface.
+2. **Broad scope (in-flight + uncleared governance: open sign-offs, BLOCKED verdicts).** Considered — richest context for "what must I re-verify." Rejected for v1: expands the forge surface and the shape warpline needs is enrichment-only; in-flight is a clean v2 extension.
+3. **Cleared-only v1, projecting `read_sei_attestations` into a posture-record shape, published as `governance_read.v1` on CLI + MCP + HTTP.** CHOSEN.
+
+## The call
+**Option 3.** `read_governance_for_sei` is a pure projection of the forge-proof admitted set (`operator_override` → `protected_override`; `signoff_cleared` → `operator_signoff`) into the frozen `governance_read.v1` shape — fail-closed (unverifiable → discriminated `unavailable`; tampered → loud raise; verified-but-none → `checked`/`[]`), a discriminated-union schema, a frozen-golden conformance oracle, on all three transports. **Scope confirmed cleared-only by warpline** (the coordination point): warpline accepts the disclosed caveat that a BLOCKED-awaiting-signoff entity reads as `absent`, and never renders `absent` as "ungoverned." Owner approved building it, directed parallel execution with warpline, and directed the local deploy of the build to the global `legis` tool.
+
+## Rationale
+Legis is the governance authority, so it owns the contract; cleared-only is the strongest *honest* answer and a **safe subset** — a future `governance_read.v2` ADDS dispositions/kinds and leaves every v1 record valid. The projection reuses the forge-proof discriminator wholesale (no new admission path, no unsigned field), so the 1.2.0 forge-resistance invariant is inherited, not re-litigated. The advisory boundary is preserved (warpline never gates; GV-LG-1 stays asserted) — this is a **federation-seam-quality** bet, NOT a north-star governance-honesty item (it fails safe; legis governance is byte-identical with/without the consumer). Validated by a 7-agent ultracode plan review (CHANGES_REQUESTED → all 8 must-fixes folded in, including a **Critical CLI false-green**: the CLI verify path used `TrailVerifier.verify` which lacks the hash-chain contiguity walk), a serial subagent-TDD build, a final opus whole-branch review (READY_TO_MERGE), my independent gate run (1335 passed, coverage 92.39%, floors hold), and a **mutation-proof** that the chain-tamper guard is load-bearing (neutering `verify_integrity()` flipped the CLI to a `checked`/`[]` false-green; the test caught it).
+
+## Reversal trigger
+- If warpline (or any consumer) needs **in-flight / uncleared** governance context (open sign-offs, BLOCKED verdicts) → `governance_read.v2` (ADD, never mutate v1) + a new PDR. (Disclosed caveat, not a defect.)
+- If the **advisory-boundary byte-identity invariant** fails (a legis verdict diverges with the read present vs absent) → reopen immediately (the guardrail).
+- If the v1 read is found to **leak an unsigned field or admit a forged/non-cleared record** → reopen (forge-resistance regression; the projection's whole safety rests on reading only admitted-attestation fields).
+- If warpline's `LegisClient` Protocol / `enrichment.governance` shape changes such that cleared-only no longer maps → reconcile the contract before any v1 publish.
+
+## Status note
+The **legis surface is done, verified, and deployed locally** (global `legis` tool rebuilt from local main → `governance_read` reachable on CLI+MCP). The **integration half** (warpline wires `LegisGovernanceClient`, restarts its MCP connection, flips its legis member `disabled`→`clean`) is **pending warpline's live confirmation** — G1 is legis-side-done, not integration-done. The contract is frozen **locally**; PUBLISH (push/PyPI) is owner-gated (see current-state escalation).
diff --git a/docs/product/decisions/0008-consume-plainweave-preflight-advisory-sibling.md b/docs/product/decisions/0008-consume-plainweave-preflight-advisory-sibling.md
new file mode 100644
index 0000000..ab68ab4
--- /dev/null
+++ b/docs/product/decisions/0008-consume-plainweave-preflight-advisory-sibling.md
@@ -0,0 +1,23 @@
+# PDR-0008 — legis consumes Plainweave's preflight-facts producer as an advisory sibling
+
+Date: 2026-06-27 Status: accepted Author: claude (opus) — **executed in a parallel legis session** (commit `27f12da`, John Morrissey, 2026-06-27 22:08); recorded here for workspace continuity
+Supersedes: — Related: commit `27f12da`; Plainweave producer `plainweave_preflight_facts_get` / envelope `weft.plainweave.preflight_facts.v1` (Plainweave ADR-006); GV-LG-3; [[0003-federation-read-doctrine]]; [[0006-warpline-preflight-conform-to-extant-mcp-envelope]]
+
+## Context
+Plainweave is a Weft sibling whose only implemented producer (`plainweave_preflight_facts_get`, envelope `weft.plainweave.preflight_facts.v1`) had **no sibling consumer** and had never been exercised end-to-end. A parallel legis session landed legis as a read-only **advisory consumer** of it, mirroring the existing warpline advisory-preflight read (PDR-0006) exactly. This is a *different* federation seam from PDR-0007's `governance_read.v1`: there legis is the **producer** (warpline consumes); here legis is the **consumer** (Plainweave produces).
+
+## The call (recorded, not re-decided)
+This is an **application of accepted doctrine** ([[0003-federation-read-doctrine]] facts-not-verdicts; [[0006-warpline-preflight-conform-to-extant-mcp-envelope]] consume-the-extant-standard), not a new bet — so it is recorded for continuity rather than re-litigated. What landed (`27f12da`):
+- `legis/plainweave_preflight/client.py` — injectable `PlainweaveMcpClient` + `StdioMcpInvoke`; every contract fault fails **closed** → `PlainweaveError`. GV-LG-3 validated against Plainweave's *real* envelope shape (`data.authority_boundary.{local_only, live_peer_calls, governance_verdicts}` + mandatory `data.freshness`/`facts`) — Plainweave's `meta` carries no `local_only`/`peer_side_effects` (those are warpline's).
+- `service/preflight.read_plainweave_preflight` — discriminated `checked`/`unavailable` sibling of `read_warpline_preflight`; None/fault → `unavailable` with a reason, NEVER `INTERNAL_ERROR`, NEVER empty-as-clean.
+- `mcp.py` `plainweave_preflight_get` tool (separate advisory sibling, not merged with warpline's); `runtime.plainweave` from `PLAINWEAVE_MCP_CMD`, default None → `unavailable`; governance unaffected when absent/unconfigured.
+
+## Rationale
+ADVISORY ONLY, enrich-only — never changes a legis policy/governance decision; the advisory boundary is the load-bearing invariant. Pinned by tests: a byte-identity test (a hostile Plainweave client cannot perturb a real verdict), a structural test (no verdict-path function references `runtime.plainweave`), a GV-LG-3 test (refuses any producer claiming `governance_verdicts`), and the honest-degrade path (absent → `unavailable`; `ok:false` → `unavailable`). Gates green at the commit: ruff, mypy, pytest **1377 passed**, per-package floors (`plainweave_preflight` 96.6%), SEI oracle, policy-boundary-check.
+
+## Reversal trigger
+- If the **advisory-boundary byte-identity invariant** fails for the Plainweave read (a legis verdict diverges with Plainweave present vs absent) → reopen immediately.
+- If Plainweave's `weft.plainweave.preflight_facts.v1` envelope changes (shape / authority_boundary fields) → the client's parse/boundary layer reopens (degrades to `unavailable` until then).
+
+## Status note
+The conformance oracle is driven over a **CONSTRUCTED** golden (built from the producer contract), NOT a live capture — the hub session's MCP wiring misroutes Plainweave. **Live end-to-end capture is a flagged follow-up** (a legis-rooted session), tracked separately by the parallel session. This PDR records the decision so the next `RESUME` does not mistake `27f12da` for unexplained drift.
diff --git a/docs/product/metrics.md b/docs/product/metrics.md
index ae9cca2..e27f3b8 100644
--- a/docs/product/metrics.md
+++ b/docs/product/metrics.md
@@ -1,4 +1,4 @@
-# Metrics — Legis Last read: 2026-06-25
+# Metrics — Legis Last read: 2026-06-28
> Legis is a governance-_honesty_ tool, not an engagement product. Its north-star
> is integrity of the honesty surface (no provable false-greens, no unverified
@@ -9,19 +9,22 @@
## North-star
| Metric | Target (falsifiable) | Current | Read on | Trend |
|--------|----------------------|---------|---------|-------|
-| Open **confirmed governance-honesty defects** (security/governance findings that let the honesty surface be bypassed or trust be trusted unverified) | = 0 by 2026-07-15 | 3 (legis-476ab6f125, -0c310712a7, -0186c23a2c) | 2026-06-24 | → |
+| Open **confirmed governance-honesty defects** (security/governance findings that let the honesty surface be bypassed or trust be trusted unverified) | = 0 by 2026-07-15 | **1, unchanged** (legis-0186c23a2c) — this session's federation work (PDR-0007 governance_read.v1, PDR-0008 Plainweave consumer) is seam-QUALITY, NOT P2 findings, so it does not move the denominator | 2026-06-28 | → |
+
+> Note (2026-06-26): closing legis-476ab6f125 surfaced two P3 follow-ups — legis-fcd59caa67 (ungated sibling reads `epoch_reset_unacknowledged` et al.) and legis-dfdeade118 (doctor `None`-cause diagnostic). These are NOT in the P2 north-star denominator (lower severity, partially mitigated / not a false-green) but are tracked so the closure is honest, not net-zero theatre.
## Input metrics (the levers that move the north-star)
| Metric | Target | Current | Read on |
|--------|--------|---------|---------|
-| Confirmed P2 security findings remaining open | 0 by 2026-07-15 | 3 | 2026-06-24 |
-| Median time-to-close on a confirmed security finding | ≤ 14 days `(set)` | unmeasured | 2026-06-24 |
+| Confirmed P2 security findings remaining open | 0 by 2026-07-15 | 1 | 2026-06-26 |
+| Median time-to-close on a confirmed security finding | ≤ 14 days (owner-set 2026-06-25) | 6 days (2 samples: legis-476ab6f125, -0c310712a7; both 2026-06-20→06-26) | 2026-06-26 |
## Guardrails (must NOT degrade)
| Metric | Floor / ceiling | Current | Read on |
|--------|-----------------|---------|---------|
-| **Advisory-boundary invariant** — governance verdicts byte-identical with a sibling (Warpline) absent vs present | must hold (binary) | **holds — proven** by `tests/mcp/test_warpline_advisory_boundary.py` (byte-identical on real verdicts) + a derived structural guard over all tool handlers; warpline consumed only in its sibling tool | 2026-06-25 |
-| CI green (tests + mypy) | 100% | `warpline-interfaces` branch (Tasks 1–8 complete): pytest 1237 passed, mypy clean (78 files), coverage 92.13% — CI-equivalent gate run locally; `main` green at 1.1.1 | 2026-06-25 |
+| **Advisory-boundary invariant** — governance verdicts byte-identical with a sibling absent vs present | must hold (binary) | **holds — re-proven ×2 this session.** (a) `governance_read.v1` (PDR-0007) is a read with no enforcement path; its 3 service fns pinned non-verdict-path in `test_warpline_advisory_boundary.py`; warpline never gates (GV-LG-1). (b) The Plainweave consumer (PDR-0008) carries its own byte-identity + structural + GV-LG-3 tests. | 2026-06-28 |
+| **New-federation-surface false-green resistance** — a new governance read never reads tamper/absence as a pass | must hold (binary) | **holds — MUTATION-PROVEN.** Neutering `verify_integrity()` in the `governance-read` CLI flipped a chain-tampered store to `{status:checked, records:[]}` exit 0 (the exact false-green); the regression test caught it. Both verify halves (chain + signatures) run on all 3 transports; tamper fails loud (HTTP 500 / MCP AUDIT_INTEGRITY_FAILURE / CLI nonzero). | 2026-06-28 |
+| CI green (tests + mypy) | 100% | **`main` @ 27f12da green** — pytest **1377 passed** at HEAD (governance_read build independently verified at 1335 @ 395d7fc; Plainweave added ~42), mypy clean, ruff clean, coverage 92.39%+, all per-package floors hold (+ new `plainweave_preflight` 96.6%), governance-gate/policy-boundary/SEI-oracle pass | 2026-06-28 |
| **Attestation classifier forge-resistance** — `attestation_get` admits no forged / non-human-cleared record | must hold (binary) | **holds** — adversarial forge phase (4 lenses, live-run probes) admitted **0** forges; admission gates on the signature marker + keys only on signed fields + integrity-bound sign-off join (PDR-0004) | 2026-06-25 |
-| Release publish gated on **live Loomweave SEI conformance** | must pass before any PyPI publish | gate in place (PR #8 line) | 2026-06-24 |
-| Test coverage vs configured floors (`scripts/check_coverage_floors.py`) | ≥ floors | **92.14% total** (floor 88); all per-package floors hold (mcp.py 92.4%, service/ 95.0%) — read on `warpline-interfaces` | 2026-06-25 |
+| Release publish gated on **live Loomweave SEI conformance** | skip-not-fail in remote CI (owner Path B, 2026-06-25); gates publish only when `LOOMWEAVE_URL` is set | **skip-not-fail** restored (PR #18/#19) — not a hard publish blocker absent a live Loomweave; the gate fires only when `LOOMWEAVE_URL` is configured | 2026-06-27 |
+| Test coverage vs configured floors (`scripts/check_coverage_floors.py`) | ≥ floors | **92.25% total** (floor 88); all 8 per-package floors hold (+ a new `warpline_preflight` floor 88, actual 91.9%) — read on `main` @ 075edd0 | 2026-06-27 |
diff --git a/docs/product/prd/PRD-0005-governance-honesty-integrity-post-gold.md b/docs/product/prd/PRD-0005-governance-honesty-integrity-post-gold.md
new file mode 100644
index 0000000..284317e
--- /dev/null
+++ b/docs/product/prd/PRD-0005-governance-honesty-integrity-post-gold.md
@@ -0,0 +1,47 @@
+# PRD-0005 — Governance-honesty integrity, post-gold Status: ready-for-planning
+Decision: PDR-0001 (north-star established this as the Now bet; the three items are codex-confirmed P2 defects, so the is-it-worth-solving call is moot — proven bugs against the gold honesty guarantee) Bet (roadmap.md): Now Target metric (metrics.md): Open confirmed governance-honesty defects
+
+## Problem
+**Who:** the governed agents and human operators who rely on Legis's posture floor, protected-cell audit chain, and policy-boundary scan being *true*, plus the sibling tools (Warpline) about to consume its attestations. **Their pain:** three confirmed code paths let the honesty guarantee be bypassed — (1) the posture hot-read trusts an *unverified* ledger tail, so a raw-DB writer can force `floor="chill"` and the routing path believes it; (2) protected records committed inside an external batch leave the HeadAnchor at the pre-batch head, so truncation back to a stale anchor goes undetected; (3) `policy_boundary_check` scans attacker-supplied absolute roots *outside* the source boundary, leaking arbitrary tree contents/parse results. **Desired outcome:** each path authenticates its input or fails closed — the surface returns to Legis's defining property, "no provable false-green; an unauthorized change is always *detectable*." **Why now:** 1.2.0 just shipped; these are the *only* confirmed open defects against the gold-line honesty guarantee, and the north-star (open governance-honesty defects → 0) is gated entirely on them.
+
+## Success metric (the signal the bet paid off)
+**Open confirmed governance-honesty defects** (metrics.md north-star): **BASELINE 3** (read 2026-06-24) → **TARGET 0**, by **2026-07-15**. Falsification: >0 confirmed honesty-bypass defects still open on 2026-07-15 → the bet missed.
+
+## Acceptance criteria (falsifiable)
+1. **SUCCESS — legis-476ab6f125 (unverified posture tail) closed.** The hot keyless `read_floor()` verifies chain integrity (`verify_integrity()`) + tail-kind discipline and **fails closed** (returns `None` → `structured`) when integrity cannot be proven. Cryptographic `operator_sig` verification under the epoch key stays the operator-side `doctor` check — it needs the operator key the routing read deliberately must not hold (matching the existing keyless-read / key-holding-doctor split). A regression test that appends a raw-DB tail record `floor="chill"` **whose chain does not verify** shows the read fails closed (red before the fix, green after); a second test **documents** that a forgery which *recomputes* the keyless chain is the conceded raw-file-write residual (README §Known security limitations, README.md:137) — made visible in the suite, never silently implied-closed. Merged to main, CI green, by 2026-07-15.
+ *Reject branch:* `read_floor()` still returns a floor from an integrity-broken ledger → finding stays open, criterion unmet.
+2. **SUCCESS — legis-0c310712a7 (un-anchored protected batch) closed.** A protected record committed inside an external `AuditStore.transaction()` advances the HeadAnchor after commit (or an owned protected-gate transaction API does). A regression test that batches a protected append then truncates to the pre-batch anchor shows truncation is **detected**. Merged, CI green, by 2026-07-15.
+ *Reject branch:* anchor still stale post-batch → unmet.
+3. **SUCCESS — legis-0186c23a2c (unbounded policy-boundary root) closed.** `policy_boundary_check` normalizes roots and requires containment inside the configured source/repo boundary. A regression test passing an absolute root outside the boundary shows the scan is **refused** (not walked). Merged, CI green, by 2026-07-15.
+ *Reject branch:* out-of-boundary root still scanned → unmet.
+4. **SUCCESS (aggregate) — north-star reads 0.** The metrics.md north-star reads **0** open confirmed governance-honesty defects on 2026-07-15.
+ *Reject branch:* >0 → bet rejected; open a follow-up PDR on the remainder.
+5. **GUARDRAIL — no false-green / fail-closed preserved.** For each of the three paths, an adversarial test proves that absent / empty / malformed / unverifiable input resolves to **BLOCKED / fail-closed / refused — never a pass**.
+ *Reject branch:* any path reads an absent/unverifiable input as a pass → bet rejected even if 1–4 pass (the cardinal sin).
+6. **GUARDRAIL — the 1.2.0 invariants stay green.** The advisory-boundary byte-identity test (`tests/mcp/test_warpline_advisory_boundary.py` + the derived structural handler guard) and the attestation forge-resistance test pass unchanged over the same window.
+ *Reject branch:* either regresses → bet rejected.
+7. **GUARDRAIL — gates hold.** Coverage floors (global ≥88; `service/` ≥95; `mcp.py` ≥~92 via `scripts/check_coverage_floors.py`) and all CI gates (pytest, mypy, ruff E4/E7/E9/F, SEI oracle, `legis policy-boundary-check`, `legis governance-gate`) are green on the merge.
+ *Reject branch:* any gate red → not acceptable.
+
+## Non-goals (this bet)
+- **Re-architecting** the posture, protected-cell, or policy subsystems — fix the bypass *in place*; structural rework is a separate bet.
+- **Changing any federation contract** (SEI consume, git-rename provider, Filigree sign-off binding, Wardline routing, Warpline preflight/attestation) — those escalate to the owner.
+- **New MCP tools** or surface additions; this bet hardens existing paths only.
+- Making Legis **tamper-*proof*** — the conceded residual threats already in the README "Known security limitations" (raw-DB-write deletion/truncation beyond the opt-in mitigation) are out of scope; the honest claim stays "detectable," not "impossible."
+
+## Constraints & guardrails
+- **Fail-closed is the contract:** every decision path must resolve an absent/empty/malformed/unverifiable input to BLOCKED/unavailable/refused, never to a pass (CLAUDE.md cardinal-sin rule).
+- **Keys stay out of agent reach:** no fix may move governance state into editable TOML or caller-controlled fields.
+- **`canonical.py` stays `ensure_ascii=False`** (byte-for-byte HMAC contract with Wardline) — no fix touches the signing canonicalization.
+- **Cadence guardrail (metrics.md input metric):** median time-to-close on a confirmed security finding ≤ 14 days (owner-set 2026-06-25). A process bound, not a headline acceptance bar.
+
+## Open questions / assumptions
+- **Assumes the three findings remain independent** (no shared fix surface). If closing one perturbs another's path (e.g., a shared store-transaction wrapper used by both posture and protected), sequencing changes — flag at planning.
+- **Assumes "confirmed governance-honesty defect" = the codex-confirmed P2 set.** If a new honesty-bypass finding lands before 2026-07-15, the north-star denominator grows and the date target may need a PDR-0001 reversal-trigger review.
+- **Assumes regression tests can simulate** the raw-DB-tail / stale-anchor / out-of-boundary attacker at unit level (per `tests/conftest.py` store isolation, no live keychain). If any proof requires the OS keychain it self-skips and drops to integration — name it in the plan.
+
+## Handoff
+- **Top item → /axiom-planning:** **legis-476ab6f125** (unverified posture tail) — the most direct false-green on a hot read path; becomes the first executable, codebase-validated implementation plan.
+- **Solution shape → /axiom-solution-architect:** the authenticate-vs-fail-closed *mechanism* per finding (e.g., does the posture read verify the full chain or a signed tail-anchor; does protected get an owned transaction API or an after-commit hook). The PRD names the boundary; the design picks the mechanism.
+- **Sequencing / forecast → /axiom-program-management:** the three findings as a sequenced set against the 2026-07-15 north-star date; this PRD emits no dated commitment.
+- **Tracker IDs:** legis-476ab6f125, legis-0c310712a7, legis-0186c23a2c.
diff --git a/docs/product/roadmap.md b/docs/product/roadmap.md
index 65afd84..f7832e9 100644
--- a/docs/product/roadmap.md
+++ b/docs/product/roadmap.md
@@ -1,12 +1,17 @@
-# Roadmap — Legis Updated: 2026-06-24 (PDR-0001)
+# Roadmap — Legis Updated: 2026-06-28 (PDR-0007, PDR-0008)
> Sequencing, WSJF / cost-of-delay, and dated forecasts are produced by
> /axiom-program-management. This file records bets as INTENT, not a delivery
> schedule. Do not compute WSJF here; hand the committed bet over for sequencing.
## Now (committed, in-flight)
-- **Governance-honesty integrity, post-gold** — keep the surface that earns the gold line _true_: close the confirmed P2 codex-security findings that let the honesty surface be bypassed (unverified posture tail, un-anchored protected batch, unbounded policy-boundary root) · tracker: legis-476ab6f125, legis-0c310712a7, legis-0186c23a2c · metric: north-star (open governance-honesty defects → 0)
-- **Federation interface readiness — Warpline seam** — publish the advisory preflight consumer + per-SEI attestation read warpline requested, without ever letting advisory context reach a verdict · tracker: legis-1734128d34 · metric: guardrail (advisory-boundary invariant holds)
+- **Governance-honesty integrity, post-gold** — keep the surface that earns the gold line _true_: close the confirmed P2 codex-security findings that let the honesty surface be bypassed. **2 of 3 done** (unverified posture tail @ eb28e4b, un-anchored protected batch @ 79b4008 — CLOSED); **remaining: unbounded policy-boundary root** · tracker: legis-0186c23a2c · metric: north-star (open governance-honesty defects → 0; now **1**)
+
+## Recently shipped (record, not in-flight)
+- **`governance_read.v1` — per-SEI governance read legis publishes for warpline** (PDR-0007) — cleared-only v1 projecting the forge-proof attestation read into a published contract on CLI+MCP+HTTP; warpline consumes advisorily (never gates). **legis-side done + verified + deployed to the local global tool; integration pending warpline's live handshake.** Push/publish owner-gated · tracker: legis-a0e286f5aa (follow-up).
+- **Plainweave preflight — advisory consumer** (PDR-0008, parallel session `27f12da`) — legis reads Plainweave's `weft.plainweave.preflight_facts.v1` producer enrich-only; first sibling consumer of that producer. Live e2e capture is a flagged follow-up. Push owner-gated.
+- **Federation interface readiness — Warpline seam** (legis-1734128d34) — advisory preflight consumer + forge-proof per-SEI attestation read · **SHIPPED in 1.2.0** (PR #17; PDR-0002/0004).
+- **Warpline preflight — conform to the extant MCP envelope** (legis-a53d92507d) — replaced the phantom-HTTP seam + mis-frozen golden with an MCP-stdio client over warpline's real envelope · **shipped to local main @ 075edd0** (push / 1.3.0 publish owner-gated; PDR-0006).
## Next (shaped, decreasing certainty)
- **v2: unify keyed signing onto operator elevation sessions** — migrate protected-cell verdict + sign-off signing onto the elevation-session primitive shipped in the posture-ratchet line · tracker: legis-11b3a3dd14, legis-2d0537655d
diff --git a/pyproject.toml b/pyproject.toml
index 2d127b1..adc7fe4 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -40,7 +40,7 @@ dev = [
"pytest>=8.0",
"pytest-cov>=5.0",
"httpx>=0.27",
- "jsonschema>=4.21",
+ "jsonschema[format]>=4.21",
"mypy>=1.19",
"ruff>=0.8",
"types-PyYAML>=6.0",
diff --git a/scripts/check_coverage_floors.py b/scripts/check_coverage_floors.py
index aa1a4f5..a99423d 100644
--- a/scripts/check_coverage_floors.py
+++ b/scripts/check_coverage_floors.py
@@ -33,6 +33,8 @@
"src/legis/api/": 88.0, # currently ~89.8
"src/legis/mcp.py": 80.0, # currently ~82
"src/legis/doctor.py": 88.0, # currently ~91
+ "src/legis/warpline_preflight/": 88.0, # currently ~92
+ "src/legis/plainweave_preflight/": 88.0, # advisory sibling consumer
}
diff --git a/site/src/pages/index.astro b/site/src/pages/index.astro
index 1e7fa62..f7a2e44 100644
--- a/site/src/pages/index.astro
+++ b/site/src/pages/index.astro
@@ -125,7 +125,7 @@ const CAPABILITIES = [
-
+
version snapshot v1.1.1 — the gold release line. Moving facts live in the repo.
@@ -145,11 +145,13 @@ const CAPABILITIES = [
state exists for that change? It owns the verdicts, the enforcement cells, the
HMAC-signed protected records, and the SEI-keyed sign-off ledger.
-
- Legis is a “forced me to do the right thing” discipline. Its worth is the
- effort the threat model forces and the residual tiers it names honestly (raw DB-file write,
- model-robustness, response-integrity-rests-on-TLS) — not a claim to withstand an attacker who
- already holds those capabilities. The system is only as load-bearing as the effort put into it.
+
+ Legis is deconfliction-first, not security — a “forced me to do the
+ right thing” discipline plus an attributable, tamper-evident record, never an
+ access-control gate, and low-security by design. Its worth is the effort the threat model forces and
+ the residual tiers it names honestly (raw DB-file write, model-robustness,
+ response-integrity-rests-on-TLS) — not a claim to withstand an attacker who already holds those
+ capabilities. Do not lean on it to keep anyone out, or for secure, regulated, or confidential data.
diff --git a/src/legis/__init__.py b/src/legis/__init__.py
index ef0fdfa..6648be9 100644
--- a/src/legis/__init__.py
+++ b/src/legis/__init__.py
@@ -1,3 +1,3 @@
"""Legis — the git/CI + governance layer of the Weft suite."""
-__version__ = "1.2.0"
+__version__ = "1.3.0"
diff --git a/src/legis/api/app.py b/src/legis/api/app.py
index f739f35..be805ac 100644
--- a/src/legis/api/app.py
+++ b/src/legis/api/app.py
@@ -62,6 +62,8 @@
from legis.service.governance import compute_override_rate as _compute_override_rate
from legis.service.governance import read_identity_gaps as _read_identity_gaps
from legis.service.governance import read_lineage_integrity as _read_lineage_integrity
+from legis.service.governance import governance_read_unavailable as _governance_read_unavailable
+from legis.service.governance import read_governance_for_sei as _read_governance_for_sei
from legis.service.governance import evaluate_policy as _evaluate_policy
from legis.service.governance import request_signoff as _request_signoff
from legis.service.governance import resolve_for_record as _resolve_for_record
@@ -870,6 +872,17 @@ def identity_gaps() -> dict:
def lineage_integrity() -> dict:
return _read_lineage_integrity(identity, verified_governance_records)
+ @app.get("/governance/sei/{sei:path}/governance-read")
+ def governance_read(sei: str) -> dict:
+ # {sei:path} captures a SEI that may contain '/'. FAIL-CLOSED pre-gate mirrors the MCP
+ # tool; verified_governance_records() runs BOTH chain + signature checks and maps tamper
+ # to HTTP 500. Missing either object -> unavailable (discriminated), never checked/[].
+ if protected_gate is None or trail_verifier is None:
+ return _governance_read_unavailable(
+ sei, "trail not signature-verifiable (no protected gate / verifier)"
+ )
+ return _read_governance_for_sei(verified_governance_records(), sei)
+
# --- agent-programmable policy grammar (WP-4.1) ---
@app.post("/policy/evaluate")
diff --git a/src/legis/cli.py b/src/legis/cli.py
index 3c7a642..995b84a 100644
--- a/src/legis/cli.py
+++ b/src/legis/cli.py
@@ -114,6 +114,15 @@ def build_parser() -> argparse.ArgumentParser:
"--db", default=gov_db_default,
help="Governance store URL (defaults to the server's governance store)",
)
+ gov_read = subparsers.add_parser(
+ "governance-read",
+ help="Read per-SEI verified governance clearances (governance_read.v1); output is always JSON",
+ )
+ gov_read.add_argument("sei", help="Stable Entity Identity (SEI) to query")
+ gov_read.add_argument(
+ "--db", default=gov_db_default,
+ help="Governance store URL (defaults to the server's governance store)",
+ )
backfill = subparsers.add_parser(
"sei-backfill",
help="Resolve legacy locator-keyed governance records through Loomweave batch resolve",
@@ -335,6 +344,74 @@ def _check_override_rate(db_url: str) -> int:
return 1 if res.status is GateStatus.FAIL else 0
+def _governance_read(db_url: str, sei: str) -> int:
+ """Per-SEI verified governance clearances (governance_read.v1) for the CLI path.
+
+ Both verification halves are mandatory (Constraint 1 / must-fix #1):
+ 1. ``store.verify_integrity()`` — the hash-chain / seq-contiguity / delete-reorder
+ half; catches a tampered non-protected record that ``TrailVerifier.verify``
+ is silent about (it only verifies records it recognises as protected).
+ 2. ``read_governance_for_sei_gate`` — the signature half, through the service
+ gate (Constraint 6), exactly as ``_check_override_rate`` does it.
+
+ Fail-closed paths: no key → unavailable; missing DB → unavailable; chain
+ tamper → exit 1; signature tamper → exit 1. No bare ``except``.
+ Output is always JSON (no ``--json`` flag needed / warning avoided).
+ """
+ import os
+
+ from legis.config import protected_policies
+ from legis.service.errors import AuditIntegrityError, ProtectedKeyRequiredError
+ from legis.service.governance import governance_read_unavailable, read_governance_for_sei_gate
+ from legis.store.audit_store import AuditStore
+
+ hmac_key = os.environ.get("LEGIS_HMAC_KEY")
+ if not hmac_key:
+ # No key → signatures are unverifiable from the CLI → unavailable, never
+ # a silent checked/[] that claims clearances were confirmed.
+ print(json.dumps(governance_read_unavailable(
+ sei, "trail not signature-verifiable (LEGIS_HMAC_KEY unset)"), sort_keys=True))
+ return 0
+
+ missing_db = _missing_sqlite_db(db_url)
+ if missing_db is not None:
+ # Absent store on a READ = unavailable (NOT the override-rate CI
+ # PASS_WITH_NOTICE axis, and NOT an auto-created empty DB read as
+ # checked/[] — that would be a false-green on the "no governance yet"
+ # case, indistinguishable from "verified and found none").
+ print(json.dumps(governance_read_unavailable(
+ sei, f"governance store not found: {missing_db}"), sort_keys=True))
+ return 0
+
+ store = AuditStore(db_url)
+ # HALF 1: hash-chain / seq-contiguity / delete-reorder defence.
+ # Must run BEFORE read_all + the service gate (which only checks
+ # signatures, not the chain). A seq gap in non-protected records
+ # passes TrailVerifier.verify silently but fails here.
+ if not store.verify_integrity():
+ print(
+ "Error: audit integrity failure: database hash chain verification failed",
+ file=sys.stderr,
+ )
+ return 1
+
+ records = store.read_all()
+ try:
+ # HALF 2: signature verification, through the service gate (Constraint 6).
+ # TamperError is wrapped into AuditIntegrityError inside the gate.
+ envelope = read_governance_for_sei_gate(
+ records, sei, hmac_key=hmac_key, protected_policies=protected_policies()
+ )
+ except (ProtectedKeyRequiredError, AuditIntegrityError) as exc:
+ # Always prefix "audit integrity" so tooling / tests can key on a single contract
+ # substring regardless of which half of the verification gate raised.
+ print(f"Error: audit integrity: {exc}", file=sys.stderr)
+ return 1
+
+ print(json.dumps(envelope, sort_keys=True))
+ return 0
+
+
def _run_doctor(args) -> int:
from legis.doctor import run_doctor
@@ -741,6 +818,9 @@ def main(argv: list[str] | None = None, *, run=uvicorn.run) -> int:
if args.command in {"check-override-rate", "governance-gate"}:
return _check_override_rate(args.db)
+ if args.command == "governance-read":
+ return _governance_read(args.db, args.sei)
+
if args.command == "sei-backfill":
report = run_pre_sei_backfill(
AuditStore(args.db),
diff --git a/src/legis/doctor.py b/src/legis/doctor.py
index b54a77b..c1fa8b6 100644
--- a/src/legis/doctor.py
+++ b/src/legis/doctor.py
@@ -727,7 +727,7 @@ def key_provider(fingerprint: str) -> str | None:
message=(
f"posture key epoch reset on {when} by {agent} — unacknowledged. The floor "
f"remains {floor}; acknowledge the reset with a signed `legis posture set` "
- "under the new key (doctor stays non-zero until then). [operator]"
+ "under the new key (doctor stays non-zero until then)."
),
repairable=False,
)
@@ -775,7 +775,7 @@ def key_provider(fingerprint: str) -> str | None:
"warn",
message=(
"operator key present in LEGIS_OPERATOR_KEY (plaintext-in-env) — usable "
- "but a residual: prefer the keychain/age backend. [operator]"
+ "but a residual: prefer the keychain/age backend."
),
)
if key_provider(epoch_fp) is not None:
@@ -785,7 +785,7 @@ def key_provider(fingerprint: str) -> str | None:
"warn",
message=(
"LEGIS_OPERATOR_KEY is present but does not match the current key epoch; "
- "another custody backend is reachable. [operator]"
+ "another custody backend is reachable."
),
)
return DoctorCheck(cid, "ok", message="operator key reachable")
@@ -795,8 +795,7 @@ def key_provider(fingerprint: str) -> str | None:
"warn",
message=(
"LEGIS_OPERATOR_KEY is present but does not match the current key epoch — "
- "`posture set` will refuse until a matching custody backend is available. "
- "[operator]"
+ "`posture set` will refuse until a matching custody backend is available."
),
)
return DoctorCheck(
@@ -805,7 +804,7 @@ def key_provider(fingerprint: str) -> str | None:
message=(
"operator key not reachable in any backend — `posture set` will refuse; "
"`legis posture rekey` to recover (mints a new epoch and preserves the "
- "current floor). [operator]"
+ "current floor)."
),
)
diff --git a/src/legis/enforcement/protected.py b/src/legis/enforcement/protected.py
index e183d2c..bb1b723 100644
--- a/src/legis/enforcement/protected.py
+++ b/src/legis/enforcement/protected.py
@@ -12,6 +12,7 @@
import logging
from collections.abc import Callable
+from contextlib import contextmanager
from dataclasses import dataclass
from typing import Any
@@ -284,10 +285,10 @@ def build(seq: int, _prev_hash: str) -> dict[str, Any]:
return payload
seq = self._store.append_signed(build)
- # Never read the head mid-batch: it is a batch-forbidden fresh-connection
- # read (Q-M5). The protected gate is not itself a batch owner, but it
- # shares the governance store with the sign-off gate, so guard defensively
- # — the next non-batch append re-advances the anchor (AUD-1 lag contract).
+ # Advance the anchor after the commit (AUD-1) — but never mid-batch: the
+ # head read is a fresh-connection read the batch forbids (Q-M5), and a
+ # per-append advance inside a batch is wasted anyway since only the final
+ # head matters. ``transaction()`` advances it once when the batch commits.
if self._anchor is not None and not self._store.in_batch():
self._anchor.update(*self._store.get_latest_sequence_and_hash())
signature = captured["signature"]
@@ -412,6 +413,29 @@ def operator_override(
extensions=extensions,
)
+ @contextmanager
+ def transaction(self):
+ """Group this gate's protected appends into one all-or-nothing batch and
+ advance the anchor once after commit — parity with
+ ``SignoffGate.transaction()`` (signoff.py).
+
+ The per-append anchor advance is deferred inside a batch (the head read
+ is batch-forbidden, Q-M5; see the ``in_batch()`` guard in
+ ``_record_signed``). Advance it once here after the batch commits and the
+ write lock is released. An exception inside the batch rolls back and
+ propagates before this runs, so the anchor never advances past a
+ rolled-back head (AUD-1: the anchor only ever lags, never overshoots).
+
+ This method must be the OUTERMOST batch owner for its store; nesting it
+ inside another gate's transaction on the same thread raises RuntimeError
+ (fail-closed, the batch-forbidden read — inherited from the
+ ``SignoffGate`` contract).
+ """
+ with self._store.transaction():
+ yield
+ if self._anchor is not None:
+ self._anchor.update(*self._store.get_latest_sequence_and_hash())
+
def records(self):
"""The governance trail this gate writes to — for verified reads."""
return self._store.read_all()
diff --git a/src/legis/mcp.py b/src/legis/mcp.py
index b70a271..91b8d3e 100644
--- a/src/legis/mcp.py
+++ b/src/legis/mcp.py
@@ -103,7 +103,9 @@
"policy_boundary_check",
"posture_get",
"warpline_preflight_get",
+ "plainweave_preflight_get",
"attestation_get",
+ "governance_read",
}
)
_OVERRIDE_RATE_NOTE = "measures operator force-pasts; not movable by agent retries"
@@ -179,6 +181,7 @@ class McpRuntime:
posture_ledger: Any | None = None
coached_engine: EnforcementEngine | None = None
warpline: Any | None = None # advisory sibling; NEVER read by a verdict path
+ plainweave: Any | None = None # advisory sibling; NEVER read by a verdict path
def _load_policy_cell_registry() -> PolicyCellRegistry:
@@ -229,19 +232,45 @@ def build_runtime(agent_id: str) -> McpRuntime:
filigree = HttpFiligreeClient(filigree_url)
warpline = None
- warpline_url = os.environ.get("WARPLINE_API_URL")
- if warpline_url:
- from legis.warpline_preflight.client import HttpWarplineClient, WarplineError
-
+ warpline_cmd = os.environ.get("WARPLINE_MCP_CMD")
+ if warpline_cmd:
+ import shlex
+ from legis.warpline_preflight.client import StdioMcpInvoke, WarplineError, WarplineMcpClient
try:
- warpline = HttpWarplineClient(warpline_url)
- except WarplineError:
+ argv = shlex.split(warpline_cmd)
+ if not argv:
+ raise WarplineError("WARPLINE_MCP_CMD is blank")
+ from legis.config import project_root
+ warpline = WarplineMcpClient(invoke=StdioMcpInvoke(command=argv), repo=str(project_root()))
+ except (WarplineError, ValueError) as exc:
logging.getLogger(__name__).warning(
- "WARPLINE_API_URL is set but invalid; warpline advisory context "
- "disabled (governance unaffected)."
- )
+ "WARPLINE_MCP_CMD is set but invalid (%s); warpline advisory context "
+ "disabled (governance unaffected).", exc)
warpline = None
+ plainweave = None
+ plainweave_cmd = os.environ.get("PLAINWEAVE_MCP_CMD")
+ if plainweave_cmd:
+ import shlex
+ from legis.plainweave_preflight.client import (
+ PlainweaveError,
+ PlainweaveMcpClient,
+ )
+ from legis.plainweave_preflight.client import (
+ StdioMcpInvoke as PlainweaveStdioMcpInvoke,
+ )
+ try:
+ argv = shlex.split(plainweave_cmd)
+ if not argv:
+ raise PlainweaveError("PLAINWEAVE_MCP_CMD is blank")
+ from legis.config import project_root
+ plainweave = PlainweaveMcpClient(invoke=PlainweaveStdioMcpInvoke(command=argv), repo=str(project_root()))
+ except (PlainweaveError, ValueError) as exc:
+ logging.getLogger(__name__).warning(
+ "PLAINWEAVE_MCP_CMD is set but invalid (%s); plainweave advisory context "
+ "disabled (governance unaffected).", exc)
+ plainweave = None
+
protected_gate = None
trail_verifier = None
signoff_gate = None
@@ -306,6 +335,7 @@ def build_runtime(agent_id: str) -> McpRuntime:
# state (audit H6 / the no-local-state-on-init invariant).
posture_ledger=PostureLedger(posture_db_url(), initialize=False),
warpline=warpline,
+ plainweave=plainweave,
)
@@ -913,6 +943,41 @@ def tool_definitions() -> list[dict[str, Any]]:
]
),
},
+ {
+ "name": "plainweave_preflight_get",
+ "description": (
+ "ADVISORY preflight context from the plainweave sibling: scoped "
+ "requirement/intent facts (envelope weft.plainweave.preflight_facts.v1, "
+ "ADR-006) over base..head. Purely advisory — NEVER a governance "
+ "verdict (plainweave emits facts only; Legis owns policy/audit). "
+ "Discriminated: 'checked' carries the advisory facts envelope; "
+ "'unavailable' (client unconfigured, transport failure, error "
+ "envelope, or payload shape mismatch) carries reasons. Never read a "
+ "missing 'checked' as 'nothing impacted'."
+ ),
+ "inputSchema": _schema(["base"], {"base": string, "head": string}),
+ "outputSchema": _one_of(
+ [
+ _schema(
+ ["status", "preflight_facts"],
+ {
+ "status": {"type": "string", "enum": ["checked"]},
+ "preflight_facts": {"type": "object"},
+ },
+ ),
+ _schema(
+ ["status", "unavailable"],
+ {
+ "status": {"type": "string", "enum": ["unavailable"]},
+ "unavailable": {
+ "type": "array",
+ "items": _schema(["reason"], {"reason": string}),
+ },
+ },
+ ),
+ ]
+ ),
+ },
{
"name": "identity_gap_list",
"description": (
@@ -1313,6 +1378,90 @@ def tool_definitions() -> list[dict[str, Any]]:
]
),
},
+ {
+ "name": "governance_read",
+ "description": (
+ "Per-SEI VERIFIED governance CLEARANCE facts (governance_read.v1). "
+ "Advisory — never gate on this. records:[] under 'checked' = no "
+ "verified clearance for this SEI on the verified trail, NOT "
+ "'ungoverned'; 'unavailable' = trail not signature-verifiable, "
+ "NOT 'absent'. A tampered trail -> AUDIT_INTEGRITY_FAILURE. "
+ "Distinct from attestation_get: projects clearance_record shape "
+ "(disposition/posture/authority/as_of/reasons), not raw attestation "
+ "fields (kind/seq)."
+ ),
+ "inputSchema": _schema(["sei"], {"sei": string}),
+ "outputSchema": _one_of(
+ [
+ # checked variant: verified trail read; records may be empty
+ _schema(
+ ["status", "sei", "records"],
+ {
+ "status": {"type": "string", "enum": ["checked"]},
+ "sei": string,
+ "records": {
+ "type": "array",
+ "items": _schema(
+ [
+ "sei",
+ "disposition",
+ "posture",
+ "authority",
+ "as_of",
+ "reasons",
+ "content_hash",
+ ],
+ {
+ "sei": string,
+ "disposition": {
+ "type": "string",
+ "enum": ["cleared"],
+ },
+ "posture": {
+ "type": "string",
+ "enum": [
+ "protected_override",
+ "operator_signoff",
+ ],
+ },
+ "authority": {
+ "type": "string",
+ "enum": ["operator"],
+ },
+ "as_of": string,
+ "reasons": {
+ "type": "array",
+ "minItems": 1,
+ "items": {
+ "type": "string",
+ "enum": [
+ "operator_override",
+ "signoff_cleared",
+ ],
+ },
+ },
+ "content_hash": string,
+ },
+ ),
+ },
+ },
+ ),
+ # unavailable variant: trail not signature-verifiable
+ _schema(
+ ["status", "sei", "records", "unavailable"],
+ {
+ "status": {"type": "string", "enum": ["unavailable"]},
+ "sei": string,
+ "records": {"type": "array", "maxItems": 0},
+ "unavailable": {
+ "type": "array",
+ "items": _schema(["reason"], {"reason": string}),
+ },
+ },
+ ),
+ ]
+ ),
+ },
]
@@ -2286,6 +2435,18 @@ def _tool_warpline_preflight_get(runtime: McpRuntime, args: dict[str, Any]) -> d
)
+def _tool_plainweave_preflight_get(runtime: McpRuntime, args: dict[str, Any]) -> dict[str, Any]:
+ from legis.service.preflight import read_plainweave_preflight
+
+ return _tool_result(
+ read_plainweave_preflight(
+ runtime.plainweave,
+ base=_require(args, "base"),
+ head=args.get("head", "HEAD"),
+ )
+ )
+
+
def _governance_trail_records(runtime: McpRuntime) -> list[Any]:
"""The verified governance trail the SEI lineage-honesty reads consume.
@@ -2330,6 +2491,26 @@ def _tool_attestation_get(runtime: McpRuntime, args: dict[str, Any]) -> dict[str
return _tool_result(read_sei_attestations(_governance_trail_records(runtime), sei))
+def _tool_governance_read(runtime: McpRuntime, args: dict[str, Any]) -> dict[str, Any]:
+ from legis.service.governance import governance_read_unavailable, read_governance_for_sei
+
+ sei = _require(args, "sei")
+ # FAIL-CLOSED pre-gate (same invariant as attestation_get): a verifiable answer
+ # needs BOTH a protected gate AND a trail_verifier. Missing either -> unavailable
+ # (discriminated), never a silent checked/[] that reads as "no clearance".
+ if runtime.protected_gate is None or runtime.trail_verifier is None:
+ return _tool_result(
+ governance_read_unavailable(
+ sei, "trail not signature-verifiable (no protected gate / verifier)"
+ )
+ )
+ # _governance_trail_records runs verified_records, which runs BOTH
+ # verify_integrity (chain/delete-reorder-truncate) AND TrailVerifier.verify
+ # (signatures), raising AuditIntegrityError (-> AUDIT_INTEGRITY_FAILURE) on a
+ # tampered protected trail (loud, never a result).
+ return _tool_result(read_governance_for_sei(_governance_trail_records(runtime), sei))
+
+
def _tool_identity_gap_list(runtime: McpRuntime, args: dict[str, Any]) -> dict[str, Any]:
return _tool_result(
read_identity_gaps(runtime.identity, lambda: _governance_trail_records(runtime))
@@ -2586,7 +2767,9 @@ def _tool_posture_get(runtime: McpRuntime, args: dict[str, Any]) -> dict[str, An
"policy_boundary_check": _tool_policy_boundary_check,
"posture_get": _tool_posture_get,
"warpline_preflight_get": _tool_warpline_preflight_get,
+ "plainweave_preflight_get": _tool_plainweave_preflight_get,
"attestation_get": _tool_attestation_get,
+ "governance_read": _tool_governance_read,
}
diff --git a/src/legis/plainweave_preflight/__init__.py b/src/legis/plainweave_preflight/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/legis/plainweave_preflight/client.py b/src/legis/plainweave_preflight/client.py
new file mode 100644
index 0000000..26b3cd9
--- /dev/null
+++ b/src/legis/plainweave_preflight/client.py
@@ -0,0 +1,158 @@
+"""Plainweave preflight client — legis reads ADVISORY requirement/intent facts.
+
+Injectable ``invoke`` seam (a stdio JSON-RPC invoker for production). SECURITY:
+Plainweave is PURELY ADVISORY. Nothing it returns may reach a governance verdict
+path (policy_evaluate, the gates, sign-off, or the honesty reads). Governance
+verdicts are byte-identical whether plainweave is available or not. Every
+contract fault fails CLOSED → PlainweaveError.
+
+Plainweave's ONE implemented producer is ``plainweave_preflight_facts_get`` →
+envelope ``weft.plainweave.preflight_facts.v1`` (ADR-006). Legis is a CONSUMER of
+that frozen envelope: it validates the envelope shape (schema / ok /
+data.authority_boundary / data.freshness / data.facts) and passes it through
+verbatim — it does NOT interpret, re-shape, or act on the facts. Plainweave's
+``meta`` carries no ``local_only``/``peer_side_effects`` (those are warpline's);
+the boundary assertions live in ``data.authority_boundary`` instead
+(``local_only`` / ``live_peer_calls`` / ``governance_verdicts``), so the GV-LG-3
+fail-closed gate validates THOSE fields.
+"""
+
+from __future__ import annotations
+
+import json
+import subprocess
+from typing import Any, Callable, Protocol, runtime_checkable
+
+Invoke = Callable[[str, "dict[str, Any]"], "Any"] # returns the parsed tool result (validated below)
+
+MAX_RESPONSE_BYTES = 1_000_000
+
+PREFLIGHT_SCHEMA = "weft.plainweave.preflight_facts.v1"
+PREFLIGHT_TOOL = "plainweave_preflight_facts_get"
+
+
+class PlainweaveError(RuntimeError):
+ """A Plainweave call failed at the transport or contract layer."""
+
+
+@runtime_checkable
+class PlainweaveClient(Protocol):
+ def preflight_facts(self, base: str, head: str) -> dict[str, Any]: ...
+
+
+class PlainweaveMcpClient:
+ """Consume plainweave's EXTANT preflight-facts MCP tool (advisory preflight).
+ Pass the frozen ``weft.plainweave.preflight_facts.v1`` envelope through verbatim
+ (the bare-object MCP output schema makes pass-through lossless). Advisory-ONLY;
+ every contract fault fails CLOSED -> PlainweaveError."""
+
+ def __init__(self, *, invoke: "Invoke", repo: str) -> None:
+ self._invoke = invoke
+ self._repo = repo
+
+ def preflight_facts(self, base: str, head: str) -> dict[str, Any]:
+ return self._call(PREFLIGHT_SCHEMA, PREFLIGHT_TOOL, base, head)
+
+ def _call(self, schema: str, tool: str, base: str, head: str) -> dict[str, Any]:
+ # The producer tool takes NO 'repo' arg (its signature is scope_kind/base/
+ # head/requirement_ids/entity_refs/baseline_id); the "which plainweave" is
+ # implicit in what the launched MCP command roots on. A commit-range scope
+ # advertises base..head; the producer never resolves the live diff itself
+ # (it honestly reports freshness "partial"). Sending an unknown kwarg would
+ # be rejected by the producer, so the args are exactly scope_kind/base/head.
+ env = self._invoke(tool, {"scope_kind": "commit_range", "base": base, "head": head})
+ if not isinstance(env, dict):
+ raise PlainweaveError(f"{tool} returned {type(env).__name__}, expected an envelope object")
+ if env.get("schema") != schema:
+ raise PlainweaveError(f"{tool} returned schema {env.get('schema')!r}, expected {schema!r}")
+ if env.get("ok") is not True: # error envelope / degraded -> unavailable
+ raise PlainweaveError(f"{tool} envelope is not ok=true: {env.get('ok')!r}")
+ data = env.get("data")
+ if not isinstance(data, dict):
+ raise PlainweaveError(f"{tool} envelope data is {type(data).__name__}, expected an object")
+ boundary = data.get("authority_boundary")
+ if not isinstance(boundary, dict): # malformed boundary fails closed (GV-LG-3 input)
+ raise PlainweaveError(
+ f"{tool} data.authority_boundary is {type(boundary).__name__}, expected an object"
+ )
+ if boundary.get("local_only") is not True:
+ raise PlainweaveError(
+ f"{tool} authority_boundary.local_only is not true: {boundary.get('local_only')!r}"
+ )
+ if boundary.get("live_peer_calls") is not False: # a live peer call is a side effect (GV-LG-3)
+ raise PlainweaveError(
+ f"{tool} authority_boundary.live_peer_calls is not false: {boundary.get('live_peer_calls')!r}"
+ )
+ if boundary.get("governance_verdicts") is not False: # this producer must NEVER claim a verdict (GV-LG-3)
+ raise PlainweaveError(
+ f"{tool} authority_boundary.governance_verdicts is not false: "
+ f"{boundary.get('governance_verdicts')!r}"
+ )
+ if "freshness" not in data or "facts" not in data: # degraded -> unavailable, not bare empty 'checked'
+ raise PlainweaveError(
+ f"{tool} envelope data is missing the mandatory 'freshness'/'facts' fields"
+ )
+ return env
+
+
+def _read_jsonrpc_result(stdout_text: str, response_id: int) -> dict:
+ for line in stdout_text.splitlines():
+ line = line.strip()
+ if not line:
+ continue
+ try:
+ msg = json.loads(line)
+ except ValueError as exc:
+ raise PlainweaveError(f"plainweave-mcp emitted a non-JSON line: {exc}") from exc
+ if isinstance(msg, dict) and msg.get("id") == response_id:
+ if "error" in msg:
+ raise PlainweaveError(f"plainweave-mcp returned a JSON-RPC error: {msg['error']}")
+ result = msg.get("result")
+ if not isinstance(result, dict):
+ raise PlainweaveError(f"plainweave-mcp result is {type(result).__name__}, expected an object")
+ return result
+ raise PlainweaveError(f"plainweave-mcp produced no JSON-RPC response for id={response_id}")
+
+
+class StdioMcpInvoke:
+ """Production Invoke: a stdio JSON-RPC call to plainweave-mcp. Fail-safe: EVERY
+ fault -> PlainweaveError. shell=False + list argv (arguments are JSON params,
+ never argv tokens); explicit command (absolute path recommended; empty
+ rejected); text=False byte-bounded stdout (post-capture); 10s timeout."""
+
+ def __init__(self, *, command: list[str], timeout: float = 10.0) -> None:
+ self._command = command
+ self._timeout = timeout
+
+ def __call__(self, tool: str, arguments: dict) -> dict:
+ if not self._command:
+ raise PlainweaveError("plainweave-mcp command is empty (PLAINWEAVE_MCP_CMD blank?)")
+ msgs = (
+ {"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "legis", "version": "1"}}},
+ {"jsonrpc": "2.0", "method": "notifications/initialized"},
+ {"jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": {"name": tool, "arguments": arguments}},
+ )
+ stdin = ("".join(json.dumps(m) + "\n" for m in msgs)).encode("utf-8")
+ try:
+ proc = subprocess.run(self._command, input=stdin, capture_output=True,
+ timeout=self._timeout, shell=False, check=False, text=False)
+ except (OSError, ValueError, subprocess.SubprocessError) as exc:
+ raise PlainweaveError(f"plainweave-mcp spawn/timeout failed: {exc}") from exc
+ if len(proc.stdout) > MAX_RESPONSE_BYTES:
+ raise PlainweaveError("plainweave-mcp response too large")
+ err = (proc.stderr or b"")[:400].decode("utf-8", "replace")
+ try:
+ result = _read_jsonrpc_result(proc.stdout.decode("utf-8", "replace"), response_id=2)
+ if result.get("isError"):
+ raise PlainweaveError(f"plainweave tool {tool} returned an error result (rc={proc.returncode}, stderr={err!r})")
+ sc = result.get("structuredContent")
+ if isinstance(sc, dict):
+ return sc
+ for block in result.get("content") or []:
+ if isinstance(block, dict) and block.get("type") == "text":
+ return json.loads(block["text"])
+ raise PlainweaveError(f"plainweave tool {tool} result had no usable envelope (rc={proc.returncode}, stderr={err!r})")
+ except PlainweaveError:
+ raise
+ except Exception as exc: # ANY parse fault fails closed
+ raise PlainweaveError(f"plainweave tool {tool} result parse failed: {exc} (rc={proc.returncode}, stderr={err!r})") from exc
diff --git a/src/legis/posture/ledger.py b/src/legis/posture/ledger.py
index 87eb7a2..2efc7f4 100644
--- a/src/legis/posture/ledger.py
+++ b/src/legis/posture/ledger.py
@@ -11,11 +11,12 @@
``None``; callers map that to the fail-closed ``structured`` default, NEVER
``chill``. Only an explicit ``GENESIS`` record makes ``chill`` the floor.
* The current floor is the latest authoritative floor record's ``floor`` field
- (``GENESIS`` / ``TRANSITION`` / ``KEY_RESET``), found by one descending
- payload scan from the tail, never the O(N) ``read_all`` loop or a repeated
- point-read loop over metadata. Metadata records such as
- ``OPERATOR_SESSION_OPENED`` must not lower the effective floor, even if they
- carry a stale ``floor`` field.
+ (``GENESIS`` / ``TRANSITION`` / ``KEY_RESET``). An O(N) keyless
+ ``verify_integrity`` walk GATES the read (fail-closed: a chain that does not
+ verify yields ``None`` -> ``structured``); the floor itself is then found by
+ one descending payload scan from the tail, never a repeated point-read loop
+ over metadata. Metadata records such as ``OPERATOR_SESSION_OPENED`` must not
+ lower the effective floor, even if they carry a stale ``floor`` field.
"""
from __future__ import annotations
@@ -92,15 +93,50 @@ def __init__(self, url: str, *, initialize: bool = True) -> None:
def read_floor(self) -> str | None:
"""The current floor (latest authoritative floor record), or ``None``.
- Single descending table scan, never a ``read_all`` loop and never a
- point-read loop over metadata tails. A missing DB file or an empty store
- both report ``None`` (fail-closed: callers map ``None`` -> ``structured``).
- Metadata records are skipped so an operator session record cannot lower
- an already-raised floor by becoming the tail.
+ Fail-closed: the floor sets routing, so the ledger must first PROVE its
+ own integrity. ``verify_integrity()`` (an O(N) keyless chain re-hash)
+ gates the read; a chain that does not verify — a raw-write in-place edit,
+ reorder, or seq gap — yields ``None`` (callers map ``None`` -> the
+ fail-closed ``structured`` default), never the tampered floor. A missing
+ DB file or an empty store also report ``None`` (``verify_integrity`` is
+ True on an empty store; the table-absence check returns ``None`` below).
+
+ The O(N) integrity walk runs on EVERY resolution (the floor is never
+ cached, design D2) and is deliberate: it is bounded by operator-action
+ volume (genesis/transition/rekey are operator-gated; session-open churn
+ is bounded by TTL expiry + human-in-the-loop enabling), immaterial at
+ posture-ledger scale on local SQLite, and an *unverified* hot read was
+ the whole bug. The two failure modes are both fail-closed but
+ asymmetric: a DETECTED tamper (a clean walk that does not verify)
+ resolves to ``None`` -> ``structured``; an I/O fault (locked/corrupt DB
+ raising ``OperationalError``) propagates as an exception and aborts the
+ read — neither is a pass. Do NOT wrap the gate in a try/except that
+ downgrades that raise to a permissive default.
+
+ SCOPE (honesty): the chain is keyless SHA, so this proves integrity +
+ tail-kind but NOT operator authorization. A file-write attacker who
+ *recomputes* the keyless chain on a forged floor-lowering ``TRANSITION``
+ passes this gate; on a non-rekeyed ledger that forgery is caught by
+ NEITHER this keyless hot read NOR ``doctor`` today — it is a PURE
+ conceded raw-file-write residual (README "Known security limitations",
+ README.md:137). ``doctor``'s keyed ``operator_sig`` verification
+ (``_transition_acknowledges``, doctor.py:649) covers ONLY the
+ ``KEY_RESET``-acknowledgment path (D6), not a ``TRANSITION`` on a ledger
+ with no reset to acknowledge. A general per-transition ``operator_sig``
+ audit in ``doctor`` would close the residual operator-side, but that is
+ separate follow-up, not this change. See
+ ``test_read_floor_fails_closed_on_integrity_break`` and
+ ``test_read_floor_recomputed_chain_forgery_is_conceded_residual``.
"""
path = _sqlite_file(self._url)
if path is not None and not path.exists():
return None
+ # Fail closed if the chain cannot prove integrity: a tampered/forged
+ # ledger must not be trusted to set the routing floor. verify_integrity()
+ # returns True on an empty store, so an absent/empty ledger still reads
+ # as None via the table-absence check below, not a spurious failure.
+ if not self.store.verify_integrity():
+ return None
self.store._assert_no_batch_in_progress("read_floor")
with self.store._engine.begin() as conn:
if not self.store._has_log_table(conn):
diff --git a/src/legis/service/__init__.py b/src/legis/service/__init__.py
index 883a87d..ac2e9a7 100644
--- a/src/legis/service/__init__.py
+++ b/src/legis/service/__init__.py
@@ -21,6 +21,9 @@
bind_signoff_issue,
compute_override_rate,
evaluate_policy,
+ governance_read_unavailable,
+ read_governance_for_sei,
+ read_governance_for_sei_gate,
read_identity_gaps,
read_lineage_integrity,
read_sei_attestations,
@@ -31,7 +34,7 @@
submit_protected_override,
verified_records,
)
-from legis.service.preflight import read_warpline_preflight
+from legis.service.preflight import read_plainweave_preflight, read_warpline_preflight
from legis.service.wardline import route_wardline_scan
__all__ = [
@@ -47,6 +50,9 @@
"RequiredInput",
"bind_signoff_issue",
"compute_override_rate",
+ "governance_read_unavailable",
+ "read_governance_for_sei",
+ "read_governance_for_sei_gate",
"read_identity_gaps",
"read_lineage_integrity",
"read_sei_attestations",
@@ -58,6 +64,7 @@
"submit_operator_override",
"submit_protected_override",
"read_warpline_preflight",
+ "read_plainweave_preflight",
"route_wardline_scan",
"verified_records",
]
diff --git a/src/legis/service/governance.py b/src/legis/service/governance.py
index 380d471..ffa813a 100644
--- a/src/legis/service/governance.py
+++ b/src/legis/service/governance.py
@@ -8,6 +8,7 @@
from __future__ import annotations
from collections.abc import Callable
+from datetime import datetime
from pathlib import Path
from typing import Any
@@ -350,6 +351,96 @@ def read_sei_attestations(verified_runtime_records: list, sei: str) -> dict[str,
return {"status": "checked", "sei": sei, "attestations": attestations}
+_POSTURE_BY_KIND = {
+ "operator_override": "protected_override", # provable: protected_cell + OVERRIDDEN_BY_OPERATOR signed
+ "signoff_cleared": "operator_signoff", # provable: SIGNED_OFF + integrity-bound request
+}
+# Three coupled points: read_sei_attestations' admitted kinds, these map keys, and the schema's
+# `posture` enum. A new clearance kind must update all three. reasons = clearance-kind code (WHAT
+# happened); posture = provable mechanism (HOW) — distinct axes, 1:1 in v1.
+
+
+def _is_rfc3339(value: Any) -> bool:
+ if not isinstance(value, str):
+ return False
+ try:
+ datetime.fromisoformat(value.replace("Z", "+00:00"))
+ return True
+ except ValueError:
+ return False
+
+
+def read_governance_for_sei(verified_runtime_records: list, sei: str) -> dict[str, Any]:
+ """Per-SEI VERIFIED GOVERNANCE CLEARANCES as the ``governance_read.v1`` envelope.
+
+ A pure PROJECTION of ``read_sei_attestations`` (the forge-proof admitted set) — adds NO admission
+ logic and reads NO unsigned field, inheriting the signature-coverage/asymmetric-error guarantees.
+ A clearance whose ``recorded_at`` is absent or non-RFC3339, or whose ``kind`` has no known
+ posture, is OMITTED (asymmetric-error: a missing clearance only wastes warpline work; a malformed
+ one is the unsafe direction). The caller owns the ``status:"unavailable"`` pre-gate via
+ ``governance_read_unavailable`` for the no-key / unverifiable-trail case.
+ """
+ att = read_sei_attestations(verified_runtime_records, sei)
+ if att.get("status") != "checked":
+ # read_sei_attestations is contracted to return "checked" here (the handler owns the
+ # unavailable pre-gate). If that ever changes, fail loud rather than relabel it "checked".
+ raise AuditIntegrityError(
+ f"read_sei_attestations returned unexpected status {att.get('status')!r}"
+ )
+ records: list[dict[str, Any]] = []
+ for a in att["attestations"]:
+ posture = _POSTURE_BY_KIND.get(a["kind"])
+ if posture is None:
+ continue # unknown kind -> omit, never fabricate a posture
+ if not _is_rfc3339(a.get("recorded_at")):
+ continue # missing / malformed timestamp -> omit, never ship as_of:null
+ records.append(
+ {
+ "sei": sei,
+ "disposition": "cleared",
+ "posture": posture,
+ "authority": "operator",
+ "as_of": a["recorded_at"],
+ "reasons": [a["kind"]],
+ "content_hash": a["content_hash"],
+ }
+ )
+ return {"status": "checked", "sei": sei, "records": records}
+
+
+def read_governance_for_sei_gate(
+ records: list, sei: str, *, hmac_key: str | None, protected_policies
+) -> dict[str, Any]:
+ """Verified governance read for the CLI/batch path: detect protected -> require key -> verify
+ signatures (fail closed) -> project. Mirrors ``evaluate_override_rate_gate`` exactly, so the CLI
+ measures the same trust the HTTP/MCP paths do (Constraint 6). The store-level hash-chain check
+ (``verify_integrity``) is the CALLER's responsibility BEFORE this call (as in
+ ``_check_override_rate``) — both halves are mandatory (Constraint 1).
+ """
+ protected_present = any(
+ _requires_protected_verification(r.payload, protected_policies) for r in records
+ )
+ if protected_present and not hmac_key:
+ raise ProtectedKeyRequiredError(
+ "Protected audit records require LEGIS_HMAC_KEY for verification"
+ )
+ if hmac_key:
+ verifier = TrailVerifier(hmac_key.encode("utf-8"), protected_policies)
+ try:
+ verifier.verify(records)
+ except TamperError as exc:
+ raise AuditIntegrityError(
+ f"Protected audit trail verification failed: {exc}"
+ ) from exc
+ return read_governance_for_sei(records, sei)
+
+
+def governance_read_unavailable(sei: str, reason: str) -> dict[str, Any]:
+ """The shared ``governance_read.v1`` unavailable envelope (one shape across all 3 adapters).
+ NEVER a silent ``checked``/``[]`` — an unverifiable trail reads as "could not check" (GOV-2)."""
+ return {"status": "unavailable", "sei": sei, "records": [], "unavailable": [{"reason": reason}]}
+
+
def _requires_protected_verification(payload: dict[str, Any], protected_policies) -> bool:
"""Gate-local protected-detection for the KEYLESS branch of the override-rate
gate: would refusing to score this record be right because it genuinely needs
diff --git a/src/legis/service/preflight.py b/src/legis/service/preflight.py
index f92f511..9bca22b 100644
--- a/src/legis/service/preflight.py
+++ b/src/legis/service/preflight.py
@@ -1,11 +1,12 @@
-"""The warpline advisory preflight read — discriminated checked/unavailable.
+"""The advisory preflight reads — discriminated checked/unavailable.
-SECURITY: warpline is PURELY ADVISORY. This read is a SIBLING of the governance
-honesty reads, never embedded in one; a failure here is contained as
-``unavailable`` and never escapes as INTERNAL_ERROR, exactly as
+SECURITY: warpline and plainweave are PURELY ADVISORY. Each read is a SIBLING of
+the governance honesty reads, never embedded in one; a failure here is contained
+as ``unavailable`` and never escapes as INTERNAL_ERROR, exactly as
``read_identity_gaps`` converts a ``LoomweaveError``. An unreachable/unconfigured
-warpline → ``unavailable`` with a reason, never an empty affected-set that reads
-as "nothing impacted".
+sibling → ``unavailable`` with a reason, never an empty fact-set that reads as
+"nothing impacted". The two reads are independent siblings — neither is merged
+into the other, and neither perturbs a governance verdict.
"""
from __future__ import annotations
@@ -36,3 +37,36 @@ def read_warpline_preflight(
"impact_radius": impact,
"reverify_worklist": worklist,
}
+
+
+def read_plainweave_preflight(
+ plainweave_client: Any | None, base: str, head: str
+) -> dict[str, Any]:
+ """Read Plainweave's ADVISORY preflight facts (envelope
+ ``weft.plainweave.preflight_facts.v1``, ADR-006) over base..head.
+
+ Mirrors ``read_warpline_preflight`` exactly: discriminated ``checked`` /
+ ``unavailable``. An unconfigured client or ANY contract fault (caught as
+ ``PlainweaveError`` — the transport converts every exception to it) degrades
+ to ``unavailable`` with a reason, NEVER an INTERNAL_ERROR and NEVER a silent
+ empty ``checked`` that reads as "nothing impacted". This is an advisory
+ SIBLING; it never changes a Legis policy/governance decision.
+ """
+ from legis.plainweave_preflight.client import PlainweaveError
+
+ if plainweave_client is None:
+ return {
+ "status": "unavailable",
+ "unavailable": [{"reason": "plainweave client not configured"}],
+ }
+ try:
+ facts = plainweave_client.preflight_facts(base, head)
+ except PlainweaveError as exc:
+ return {
+ "status": "unavailable",
+ "unavailable": [{"reason": f"plainweave check failed: {exc}"}],
+ }
+ return {
+ "status": "checked",
+ "preflight_facts": facts,
+ }
diff --git a/src/legis/warpline_preflight/client.py b/src/legis/warpline_preflight/client.py
index 04a0f2f..d535ca9 100644
--- a/src/legis/warpline_preflight/client.py
+++ b/src/legis/warpline_preflight/client.py
@@ -1,34 +1,25 @@
"""Warpline preflight client — legis reads ADVISORY impact/reverify hints.
-Stdlib ``urllib`` with an injectable ``fetch`` so tests run offline; no new
-dependency. SECURITY: Warpline is PURELY ADVISORY. This client exposes only
-read-only GETs; nothing it returns may reach a governance verdict path
+Injectable ``invoke`` seam (MCP stdio invoker is Task 2). SECURITY: Warpline
+is PURELY ADVISORY. Nothing it returns may reach a governance verdict path
(policy_evaluate, the gates, sign-off, or the honesty reads). Governance
-verdicts are byte-identical whether WARPLINE_API_URL is set or unset.
+verdicts are byte-identical whether warpline is available or not. Every
+contract fault fails CLOSED → WarplineError.
"""
from __future__ import annotations
import json
-import http.client
-import ipaddress
-import logging
-import os
-import urllib.error
-import urllib.parse
-import urllib.request
+import subprocess
from typing import Any, Callable, Protocol, runtime_checkable
-Fetch = Callable[[str, str, "dict | None"], dict]
+Invoke = Callable[[str, "dict[str, Any]"], "Any"] # returns the parsed tool result (validated below)
-logger = logging.getLogger(__name__)
+MAX_RESPONSE_BYTES = 1_000_000
class WarplineError(RuntimeError):
- """A Warpline call failed at the transport or decode layer."""
-
-
-MAX_RESPONSE_BYTES = 1_000_000
+ """A Warpline call failed at the transport or contract layer."""
@runtime_checkable
@@ -37,107 +28,103 @@ def impact_radius(self, base: str, head: str) -> dict[str, Any]: ...
def reverify_worklist(self, base: str, head: str) -> dict[str, Any]: ...
-def _urllib_fetch(
- method: str, url: str, body: dict | None, headers: dict[str, str] | None = None
-) -> dict:
- data = json.dumps(body).encode("utf-8") if body is not None else None
- req = urllib.request.Request(url, data=data, method=method)
- if data is not None:
- req.add_header("Content-Type", "application/json")
- for name, value in (headers or {}).items():
- req.add_header(name, value)
- try:
- with _open_no_redirect(req) as resp: # noqa: S310 (trusted Warpline URL)
- decoded = _decode_json_response(resp, f"{method} {url}")
- except urllib.error.HTTPError as exc:
- if 300 <= exc.code < 400:
- raise WarplineError(f"{method} {url} redirect not allowed: {exc.code}") from exc
- raise WarplineError(f"{method} {url} failed: {exc}") from exc
- except (urllib.error.URLError, ValueError, OSError, http.client.HTTPException) as exc:
- raise WarplineError(f"{method} {url} failed: {exc}") from exc
- return _require_dict(decoded, f"{method} {url}")
-
-
-class _NoRedirectHandler(urllib.request.HTTPRedirectHandler):
- def redirect_request(self, req, fp, code, msg, headers, newurl): # type: ignore[override]
- return None
-
-
-def _open_no_redirect(req: urllib.request.Request) -> Any:
- opener = urllib.request.build_opener(_NoRedirectHandler())
- return opener.open(req, timeout=10.0)
-
-
-def _decode_json_response(resp: Any, context: str) -> Any:
- headers = getattr(resp, "headers", {}) or {}
- content_type = headers.get("Content-Type", "application/json")
- if "json" not in content_type.lower():
- raise WarplineError(f"{context} returned non-JSON content type: {content_type}")
- raw = resp.read(MAX_RESPONSE_BYTES + 1)
- if len(raw) > MAX_RESPONSE_BYTES:
- raise WarplineError(f"{context} response too large")
- return json.loads(raw.decode("utf-8"))
-
-
-def _require_dict(value: Any, context: str) -> dict[str, Any]:
- if not isinstance(value, dict):
- raise WarplineError(f"{context} returned {type(value).__name__}, expected object")
- return value
-
-
-def _is_loopback(host: str) -> bool:
- if host == "localhost":
- return True
- try:
- return ipaddress.ip_address(host).is_loopback
- except ValueError:
- return False
-
-
-def _validate_base_url(base_url: str) -> str:
- parsed = urllib.parse.urlparse(base_url)
- if parsed.scheme not in {"http", "https"} or not parsed.hostname:
- raise WarplineError("Warpline base URL must be an http(s) URL with a host")
- allow_insecure_remote = os.environ.get("LEGIS_ALLOW_INSECURE_REMOTE_HTTP") == "1"
- if parsed.scheme == "http" and not _is_loopback(parsed.hostname):
- if not allow_insecure_remote:
- raise WarplineError("Warpline base URL must use HTTPS unless it is loopback")
- # ID-SEI-1: plaintext to a remote Warpline. TLS is the only integrity
- # control on responses (the request HMAC authenticates requests, not
- # responses), so an on-path attacker can tamper with what legis reads
- # back. Dev/loopback only; never production.
- logger.warning(
- "LEGIS_ALLOW_INSECURE_REMOTE_HTTP=1 is permitting a plaintext HTTP "
- "connection to non-loopback Warpline host %r; responses are forgeable "
- "without TLS. Dev/loopback use only.",
- parsed.hostname,
- )
- return base_url.rstrip("/")
+_IMPACT = ("warpline.impact_radius.v1", "warpline_impact_radius_get")
+_REVERIFY = ("warpline.reverify_worklist.v1", "warpline_reverify_worklist_get")
-class HttpWarplineClient:
- def __init__(
- self,
- base_url: str,
- *,
- fetch: Fetch | None = None,
- ) -> None:
- self._base = _validate_base_url(base_url)
- self._fetch = fetch if fetch is not None else self._transport_fetch
+class WarplineMcpClient:
+ """Consume warpline's EXTANT MCP tools (advisory preflight). Pass the frozen
+ envelope through verbatim (the bare-object MCP output schema makes pass-through
+ lossless). Advisory-ONLY; every contract fault fails CLOSED -> WarplineError."""
- def _transport_fetch(self, method: str, url: str, body: dict | None) -> dict:
- return _urllib_fetch(method, url, body, {})
+ def __init__(self, *, invoke: "Invoke", repo: str) -> None:
+ self._invoke = invoke
+ self._repo = repo
def impact_radius(self, base: str, head: str) -> dict[str, Any]:
- q = urllib.parse.urlencode({"base": base, "head": head})
- return _require_dict(
- self._fetch("GET", f"{self._base}/api/impact-radius?{q}", None),
- "Warpline impact_radius",
- )
+ return self._call(*_IMPACT, base, head)
def reverify_worklist(self, base: str, head: str) -> dict[str, Any]:
- q = urllib.parse.urlencode({"base": base, "head": head})
- return _require_dict(
- self._fetch("GET", f"{self._base}/api/reverify-worklist?{q}", None),
- "Warpline reverify_worklist",
+ return self._call(*_REVERIFY, base, head)
+
+ def _call(self, schema: str, tool: str, base: str, head: str) -> dict[str, Any]:
+ env = self._invoke(tool, {"repo": self._repo, "rev_range": f"{base}..{head}"})
+ if not isinstance(env, dict):
+ raise WarplineError(f"{tool} returned {type(env).__name__}, expected an envelope object")
+ if env.get("schema") != schema:
+ raise WarplineError(f"{tool} returned schema {env.get('schema')!r}, expected {schema!r}")
+ if env.get("ok") is not True:
+ raise WarplineError(f"{tool} envelope is not ok=true: {env.get('ok')!r}")
+ meta = env.get("meta")
+ if not isinstance(meta, dict): # malformed meta fails closed (GV-LG-3 input)
+ raise WarplineError(f"{tool} envelope meta is {type(meta).__name__}, expected an object")
+ if meta.get("local_only") is not True:
+ raise WarplineError(f"{tool} meta.local_only is not true: {meta.get('local_only')!r}")
+ if meta.get("peer_side_effects"):
+ raise WarplineError(f"{tool} claims a peer side effect (GV-LG-3): {meta.get('peer_side_effects')!r}")
+ data = env.get("data")
+ if not isinstance(data, dict) or "completeness" not in data: # degraded -> unavailable, not bare empty 'checked'
+ raise WarplineError(f"{tool} envelope data is missing the mandatory 'completeness' field")
+ return env
+
+
+def _read_jsonrpc_result(stdout_text: str, response_id: int) -> dict:
+ for line in stdout_text.splitlines():
+ line = line.strip()
+ if not line:
+ continue
+ try:
+ msg = json.loads(line)
+ except ValueError as exc:
+ raise WarplineError(f"warpline-mcp emitted a non-JSON line: {exc}") from exc
+ if isinstance(msg, dict) and msg.get("id") == response_id:
+ if "error" in msg:
+ raise WarplineError(f"warpline-mcp returned a JSON-RPC error: {msg['error']}")
+ result = msg.get("result")
+ if not isinstance(result, dict):
+ raise WarplineError(f"warpline-mcp result is {type(result).__name__}, expected an object")
+ return result
+ raise WarplineError(f"warpline-mcp produced no JSON-RPC response for id={response_id}")
+
+
+class StdioMcpInvoke:
+ """Production Invoke: a stdio JSON-RPC call to warpline-mcp. Fail-safe: EVERY
+ fault -> WarplineError. shell=False + list argv (rev_range is a JSON param, never
+ an argv token); explicit command (absolute path recommended; empty rejected);
+ text=False byte-bounded stdout (post-capture; see the cap note); 10s timeout."""
+ def __init__(self, *, command: list[str], timeout: float = 10.0) -> None:
+ self._command = command
+ self._timeout = timeout
+
+ def __call__(self, tool: str, arguments: dict) -> dict:
+ if not self._command:
+ raise WarplineError("warpline-mcp command is empty (WARPLINE_MCP_CMD blank?)")
+ msgs = (
+ {"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "legis", "version": "1"}}},
+ {"jsonrpc": "2.0", "method": "notifications/initialized"},
+ {"jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": {"name": tool, "arguments": arguments}},
)
+ stdin = ("".join(json.dumps(m) + "\n" for m in msgs)).encode("utf-8")
+ try:
+ proc = subprocess.run(self._command, input=stdin, capture_output=True,
+ timeout=self._timeout, shell=False, check=False, text=False)
+ except (OSError, ValueError, subprocess.SubprocessError) as exc:
+ raise WarplineError(f"warpline-mcp spawn/timeout failed: {exc}") from exc
+ if len(proc.stdout) > MAX_RESPONSE_BYTES:
+ raise WarplineError("warpline-mcp response too large")
+ err = (proc.stderr or b"")[:400].decode("utf-8", "replace")
+ try:
+ result = _read_jsonrpc_result(proc.stdout.decode("utf-8", "replace"), response_id=2)
+ if result.get("isError"):
+ raise WarplineError(f"warpline tool {tool} returned an error result (rc={proc.returncode}, stderr={err!r})")
+ sc = result.get("structuredContent")
+ if isinstance(sc, dict):
+ return sc
+ for block in result.get("content") or []:
+ if isinstance(block, dict) and block.get("type") == "text":
+ return json.loads(block["text"])
+ raise WarplineError(f"warpline tool {tool} result had no usable envelope (rc={proc.returncode}, stderr={err!r})")
+ except WarplineError:
+ raise
+ except Exception as exc: # ANY parse fault fails closed
+ raise WarplineError(f"warpline tool {tool} result parse failed: {exc} (rc={proc.returncode}, stderr={err!r})") from exc
diff --git a/tests/api/test_governance_read_route.py b/tests/api/test_governance_read_route.py
new file mode 100644
index 0000000..7ea88f3
--- /dev/null
+++ b/tests/api/test_governance_read_route.py
@@ -0,0 +1,207 @@
+"""HTTP route ``GET /governance/sei/{sei:path}/governance-read`` tests.
+
+TDD for Task 4. Mirrors the MCP governance_read tool tests (test_governance_read_tool.py)
+and the identity-gap/lineage-integrity route tests (test_sei_api.py).
+
+Cases:
+ (a) wired gate+verifier + a clearance -> 200 {status:checked, records:[…]}
+ (b) no gate/verifier -> 200 {status:unavailable, …}
+ (c) tampered trail -> 500
+ (d) a SEI containing '/' round-trips correctly ({sei:path} capture)
+
+FAIL-CLOSED invariant: verified_governance_records() runs BOTH chain + signature
+checks and maps tamper to HTTP 500; absent gate/verifier yields discriminated
+``unavailable``, NEVER a silent ``checked``/``[]``.
+"""
+from __future__ import annotations
+
+import hashlib
+import json
+
+import pytest
+from fastapi.testclient import TestClient
+
+from legis.api.app import create_app
+from legis.canonical import canonical_json, content_hash
+from legis.clock import FixedClock
+from legis.enforcement.engine import EnforcementEngine
+from legis.enforcement.protected import ProtectedGate, TrailVerifier
+from legis.enforcement.verdict import JudgeOpinion, Verdict
+from legis.identity.entity_key import EntityKey
+from legis.policy.cells import PolicyCellRegistry, PolicyCellRule
+from legis.posture.ledger import PostureLedger
+from legis.store.audit_store import GENESIS, AuditStore, _chain
+
+pytestmark = pytest.mark.usefixtures("unsafe_dev_auth")
+
+_CLOCK_ISO = "2026-06-02T12:00:00+00:00"
+_KEY = b"route-test-key"
+_POLICY = "protected.governance"
+_SEI = "loomweave:eid:governance-route-test"
+_PROTECTED = frozenset({_POLICY})
+
+
+class _AdvisoryJudge:
+ def evaluate(self, record): # noqa: ANN001
+ return JudgeOpinion(Verdict.BLOCKED, "judge@1", "advisory only")
+
+
+def _genesis_ledger(tmp_path):
+ url = f"sqlite:///{tmp_path / 'posture.db'}"
+ ledger = PostureLedger(url, initialize=True)
+ fp = hashlib.sha256(b"k" * 32).hexdigest()
+ ledger.genesis(key_fingerprint=fp, agent_id="installer", recorded_at="t0")
+ return ledger
+
+
+def _registry():
+ return PolicyCellRegistry(
+ default_cell="chill",
+ rules=(PolicyCellRule(pattern=_POLICY, cell="protected"),),
+ )
+
+
+def _simple_app(tmp_path):
+ """App WITHOUT protected_gate / trail_verifier — triggers the unavailable pre-gate."""
+ store = AuditStore(f"sqlite:///{tmp_path / 'gov.db'}")
+ eng = EnforcementEngine(store, FixedClock(_CLOCK_ISO))
+ app = create_app(
+ enforcement=eng,
+ cell_registry=PolicyCellRegistry(default_cell="chill"),
+ posture_ledger=_genesis_ledger(tmp_path),
+ )
+ return TestClient(app)
+
+
+def _wired_app_with_clearance(tmp_path):
+ """App with BOTH gate + verifier wired, and a genuine operator_override clearance
+ for _SEI already written to the shared store (mirrors _wired_runtime_with_clearance
+ from test_governance_read_tool.py)."""
+ store = AuditStore(f"sqlite:///{tmp_path / 'gov.db'}")
+ clock = FixedClock(_CLOCK_ISO)
+
+ # Write a genuine OVERRIDDEN_BY_OPERATOR record directly (mirrors MCP fixture)
+ pg_write = ProtectedGate(
+ store,
+ clock,
+ judge=_AdvisoryJudge(),
+ key=_KEY,
+ protected_policies=_PROTECTED,
+ )
+ pg_write.operator_override(
+ policy=_POLICY,
+ entity_key=EntityKey(value=_SEI, identity_stable=True),
+ rationale="approved",
+ operator_id="op-sec",
+ file_fingerprint="sha256:abc",
+ ast_path="Module/Call",
+ extensions={"loomweave": {"content_hash": "blake3:clearance-override"}},
+ )
+
+ # Build the read-side app using the SAME store (so it sees the written record)
+ pg_read = ProtectedGate(
+ store,
+ clock,
+ judge=_AdvisoryJudge(),
+ key=_KEY,
+ protected_policies=_PROTECTED,
+ )
+ app = create_app(
+ protected_gate=pg_read,
+ trail_verifier=TrailVerifier(_KEY, _PROTECTED),
+ cell_registry=_registry(),
+ posture_ledger=_genesis_ledger(tmp_path),
+ )
+ return TestClient(app), store
+
+
+# --- (a) verified trail with clearance -> 200 {status:checked, records:[…]} ---
+
+def test_governance_read_checked_with_clearance(tmp_path):
+ """Wired gate + verifier + a genuine clearance -> 200 checked with one record."""
+ client, _store = _wired_app_with_clearance(tmp_path)
+ resp = client.get(f"/governance/sei/{_SEI}/governance-read")
+ assert resp.status_code == 200
+ body = resp.json()
+ assert body["status"] == "checked"
+ assert body["sei"] == _SEI
+ assert len(body["records"]) == 1
+ rec = body["records"][0]
+ assert rec["disposition"] == "cleared"
+ assert rec["posture"] == "protected_override"
+ assert rec["authority"] == "operator"
+ assert rec["sei"] == _SEI
+ assert rec["reasons"] == ["operator_override"]
+ assert rec["content_hash"] == "blake3:clearance-override"
+ assert rec["as_of"] == _CLOCK_ISO
+
+
+# --- (b) no gate/verifier -> 200 {status:unavailable, …} ---
+
+def test_governance_read_unavailable_when_no_gate(tmp_path):
+ """No protected_gate wired -> 200 unavailable (discriminated), NEVER checked/[]."""
+ client = _simple_app(tmp_path)
+ resp = client.get(f"/governance/sei/{_SEI}/governance-read")
+ assert resp.status_code == 200
+ body = resp.json()
+ assert body["status"] == "unavailable"
+ assert body["sei"] == _SEI
+ assert body["records"] == []
+ assert body.get("unavailable") and "reason" in body["unavailable"][0]
+
+
+# --- (c) tampered trail -> 500 ---
+
+def test_governance_read_500_on_tamper(tmp_path):
+ """A signature-tampered trail must propagate as HTTP 500 (fail closed)."""
+ client, store = _wired_app_with_clearance(tmp_path)
+
+ # Confirm the clean trail reads 200 first
+ assert client.get(f"/governance/sei/{_SEI}/governance-read").status_code == 200
+
+ # Corrupt a signed field on the record (like judge_verdict) to break the
+ # HMAC signature — mirrors _tamper_first_record in test_complex_api.py.
+ import sqlite3
+
+ db = str(tmp_path / "gov.db")
+ con = sqlite3.connect(db)
+ con.execute("DROP TRIGGER IF EXISTS audit_log_no_update")
+ seq, payload = con.execute(
+ "SELECT seq, payload FROM audit_log ORDER BY seq ASC LIMIT 1"
+ ).fetchone()
+ p = json.loads(payload)
+ # Flip the signed judge_verdict field to break the HMAC signature
+ p.setdefault("extensions", {})["judge_verdict"] = "FORGED"
+ con.execute("UPDATE audit_log SET payload=? WHERE seq=?", (canonical_json(p), seq))
+ # Re-chain so the unkeyed hash-chain check still passes (only HMAC is broken)
+ prev = GENESIS
+ for s, pl in con.execute(
+ "SELECT seq, payload FROM audit_log ORDER BY seq ASC"
+ ).fetchall():
+ ch = content_hash(json.loads(pl))
+ con.execute(
+ "UPDATE audit_log SET content_hash=?, prev_hash=?, chain_hash=? WHERE seq=?",
+ (ch, prev, _chain(prev, ch), s),
+ )
+ prev = _chain(prev, ch)
+ con.commit()
+ con.close()
+
+ # Now the HMAC signature check must fail -> AuditIntegrityError -> HTTP 500
+ resp = client.get(f"/governance/sei/{_SEI}/governance-read")
+ assert resp.status_code == 500
+
+
+# --- (d) SEI containing '/' round-trips via {sei:path} capture ---
+
+def test_governance_read_slash_bearing_sei_round_trips(tmp_path):
+ """A SEI with embedded '/' is captured correctly by {sei:path} and echoed back."""
+ slash_sei = "loomweave:eid:namespace/entity-id"
+ client = _simple_app(tmp_path)
+ # Use the unavailable (no-gate) path — it echoes sei without needing records.
+ resp = client.get(f"/governance/sei/{slash_sei}/governance-read")
+ assert resp.status_code == 200
+ body = resp.json()
+ # The key invariant: {sei:path} round-tripped the slash-bearing SEI intact.
+ assert body["sei"] == slash_sei
+ assert body["status"] == "unavailable"
diff --git a/tests/cli/test_governance_read_cli.py b/tests/cli/test_governance_read_cli.py
new file mode 100644
index 0000000..95e661c
--- /dev/null
+++ b/tests/cli/test_governance_read_cli.py
@@ -0,0 +1,209 @@
+"""Task 5 tests: legis governance-read CLI subcommand.
+
+TDD red→green. Cases:
+ (a) LEGIS_HMAC_KEY set + verifiable store with a clearance → exit 0, JSON {status:checked, records:[…]}
+ (b) NO LEGIS_HMAC_KEY → exit 0, JSON {status:unavailable,…}
+ (c) CHAIN-tampered store (delete a non-protected record, creating a seq gap) →
+ exit nonzero, "audit integrity" in stderr.
+ CRITICAL: this test must be RED if verify_integrity() is omitted. Only
+ verify_integrity() detects a seq gap; TrailVerifier.verify is a no-op
+ on non-protected records, so a CLI that skips verify_integrity() would
+ return exit 0 / {status:checked, records:[]} (false-green).
+ (d) Signature-tampered protected store (key mismatch) → exit nonzero,
+ "audit integrity" in stderr (same contract substring as all other tamper cases).
+ (e) Missing/relocated DB (key set) → exit 0, JSON {status:unavailable,…},
+ NOT a silent {status:checked, records:[]} from an auto-created empty DB.
+"""
+
+from __future__ import annotations
+
+import json
+import sqlite3
+
+from legis.cli import main
+from legis.clock import FixedClock
+from legis.enforcement.protected import ProtectedGate
+from legis.enforcement.verdict import JudgeOpinion, Verdict
+from legis.identity.entity_key import EntityKey
+from legis.store.audit_store import AuditStore
+
+_SEI = "loomweave:eid:cli-governance-read-test"
+_CLOCK = FixedClock("2026-06-03T10:00:00+00:00")
+_KEY = b"cli-governance-key"
+_KEY_STR = "cli-governance-key"
+_POLICY = "protected.cli-test"
+
+
+class _AcceptJudge:
+ def evaluate(self, record): # noqa: ANN001
+ return JudgeOpinion(verdict=Verdict.ACCEPTED, model="judge@1", rationale="ok")
+
+
+def _db_url(db_path) -> str:
+ return f"sqlite:///{db_path}"
+
+
+def _write_clearance(db_path) -> None:
+ """Write a genuine protected operator_override clearance to the store."""
+ store = AuditStore(_db_url(db_path))
+ gate = ProtectedGate(store, _CLOCK, judge=_AcceptJudge(), key=_KEY)
+ gate.operator_override(
+ policy=_POLICY,
+ entity_key=EntityKey(value=_SEI, identity_stable=True),
+ rationale="operator clears",
+ operator_id="operator-1",
+ file_fingerprint="sha256:ff",
+ ast_path="Module.Call",
+ extensions={"loomweave": {"content_hash": "ch:test-content"}},
+ )
+
+
+# --------------------------------------------------------------------------- #
+# (a) LEGIS_HMAC_KEY set + verifiable store with clearance → checked #
+# --------------------------------------------------------------------------- #
+
+
+def test_cli_governance_read_checked_with_clearance(tmp_path, monkeypatch, capsys):
+ """Verifiable protected store + LEGIS_HMAC_KEY → exit 0, status:checked with records."""
+ db_path = tmp_path / "gov.db"
+ _write_clearance(db_path)
+ monkeypatch.setenv("LEGIS_HMAC_KEY", _KEY_STR)
+ monkeypatch.setenv("LEGIS_PROTECTED_POLICIES", _POLICY)
+
+ rc = main(["governance-read", "--db", _db_url(db_path), _SEI])
+ assert rc == 0
+ out, err = capsys.readouterr()
+ assert not err, f"unexpected stderr: {err!r}"
+ envelope = json.loads(out)
+ assert envelope["status"] == "checked"
+ assert envelope["sei"] == _SEI
+ assert len(envelope["records"]) == 1
+ rec = envelope["records"][0]
+ assert rec["disposition"] == "cleared"
+ assert rec["posture"] == "protected_override"
+ assert rec["authority"] == "operator"
+ assert rec["content_hash"] == "ch:test-content"
+ assert rec["reasons"] == ["operator_override"]
+ assert rec["as_of"] == "2026-06-03T10:00:00+00:00"
+
+
+# --------------------------------------------------------------------------- #
+# (b) NO LEGIS_HMAC_KEY → unavailable (can't verify sigs) #
+# --------------------------------------------------------------------------- #
+
+
+def test_cli_governance_read_no_key_is_unavailable(tmp_path, monkeypatch, capsys):
+ """Without LEGIS_HMAC_KEY, signatures are unverifiable → unavailable (NOT silent checked/[])."""
+ db_path = tmp_path / "gov.db"
+ _write_clearance(db_path)
+ monkeypatch.delenv("LEGIS_HMAC_KEY", raising=False)
+
+ rc = main(["governance-read", "--db", _db_url(db_path), _SEI])
+ assert rc == 0
+ out, err = capsys.readouterr()
+ assert not err, f"unexpected stderr: {err!r}"
+ envelope = json.loads(out)
+ assert envelope["status"] == "unavailable"
+ assert envelope["sei"] == _SEI
+ assert envelope["records"] == []
+ reasons = envelope.get("unavailable", [])
+ assert reasons and "reason" in reasons[0]
+ assert "LEGIS_HMAC_KEY" in reasons[0]["reason"]
+
+
+# --------------------------------------------------------------------------- #
+# (c) CHAIN-tampered store: seq gap in non-protected records → exit nonzero #
+# CRITICAL: this test must be RED if verify_integrity() is omitted from the #
+# CLI helper. TrailVerifier.verify is a no-op on non-protected records; #
+# only verify_integrity() detects the seq contiguity gap. #
+# --------------------------------------------------------------------------- #
+
+
+def test_cli_governance_read_chain_tamper_exits_nonzero(tmp_path, monkeypatch, capsys):
+ """Seq-gap (non-protected records, trigger-bypassed delete) → exit 1, 'audit integrity' in stderr.
+
+ If verify_integrity() is omitted, the CLI would return exit 0 / {status:checked, records:[]}
+ (false-green) because TrailVerifier.verify passes silently on non-protected records.
+ """
+ db_path = tmp_path / "gov.db"
+ store = AuditStore(_db_url(db_path))
+ # Plain (non-protected) records so TrailVerifier is a no-op on them.
+ store.append({"event": "PLAIN_1"})
+ store.append({"event": "PLAIN_2"})
+ store.append({"event": "PLAIN_3"})
+
+ # Bypass the append-only triggers and delete the middle record, creating
+ # a seq gap (seq: 1, 3) that verify_integrity() detects.
+ conn = sqlite3.connect(str(db_path))
+ try:
+ conn.execute("DROP TRIGGER IF EXISTS audit_log_no_delete")
+ conn.execute("DELETE FROM audit_log WHERE seq = 2")
+ conn.commit()
+ finally:
+ conn.close()
+
+ monkeypatch.setenv("LEGIS_HMAC_KEY", _KEY_STR)
+
+ rc = main(["governance-read", "--db", _db_url(db_path), _SEI])
+ assert rc != 0
+ out, err = capsys.readouterr()
+ # Must report the integrity failure on stderr with "audit integrity"
+ assert "audit integrity" in err.lower(), f"stderr was: {err!r}"
+ # Must NOT print a false-green JSON envelope on stdout
+ assert not out.strip() or "checked" not in out
+
+
+# --------------------------------------------------------------------------- #
+# (d) Signature-tampered protected store via key mismatch → exit nonzero #
+# --------------------------------------------------------------------------- #
+
+
+def test_cli_governance_read_sig_tamper_exits_nonzero(tmp_path, monkeypatch, capsys):
+ """Protected store signed with _KEY; CLI uses a different key.
+
+ The SHA hash chain is consistent (verify_integrity passes); TrailVerifier with
+ the wrong key detects the HMAC mismatch → AuditIntegrityError → exit 1.
+ """
+ db_path = tmp_path / "gov.db"
+ _write_clearance(db_path) # signed with _KEY = b"cli-governance-key"
+
+ # Wrong key — signatures cannot verify
+ monkeypatch.setenv("LEGIS_HMAC_KEY", "wrong-key-does-not-match")
+ monkeypatch.setenv("LEGIS_PROTECTED_POLICIES", _POLICY)
+
+ rc = main(["governance-read", "--db", _db_url(db_path), _SEI])
+ assert rc != 0
+ out, err = capsys.readouterr()
+ # All tamper cases (chain AND signature) must emit "audit integrity" on stderr —
+ # uniform contract substring used by tooling and tests across mcp.py / cli.py.
+ assert "audit integrity" in err.lower(), f"stderr was: {err!r}"
+
+
+# --------------------------------------------------------------------------- #
+# (e) Missing/relocated DB (with key set) → unavailable, NOT a false-green #
+# --------------------------------------------------------------------------- #
+
+
+def test_cli_governance_read_missing_db_is_unavailable(tmp_path, monkeypatch, capsys):
+ """Missing DB → {status:unavailable}, exit 0.
+
+ Must NOT auto-create an empty DB and return {status:checked, records:[]}.
+ LEGIS_HMAC_KEY must be set so the no-key early-return (case b) does not
+ fire before the missing-DB check is reached.
+ """
+ nonexistent = tmp_path / "not_there.db"
+ assert not nonexistent.exists()
+ monkeypatch.setenv("LEGIS_HMAC_KEY", _KEY_STR)
+
+ rc = main(["governance-read", "--db", _db_url(nonexistent), _SEI])
+ assert rc == 0
+ out, err = capsys.readouterr()
+ assert not err, f"unexpected stderr: {err!r}"
+ envelope = json.loads(out)
+ assert envelope["status"] == "unavailable"
+ assert envelope["sei"] == _SEI
+ assert envelope["records"] == []
+ reasons = envelope.get("unavailable", [])
+ assert reasons and "governance store not found" in reasons[0]["reason"]
+ # DB must not have been auto-created
+ assert not nonexistent.exists()
diff --git a/tests/conformance/fixtures/legis-governance-read.golden.json b/tests/conformance/fixtures/legis-governance-read.golden.json
new file mode 100644
index 0000000..898fd1a
--- /dev/null
+++ b/tests/conformance/fixtures/legis-governance-read.golden.json
@@ -0,0 +1,28 @@
+{
+ "records": [
+ {
+ "as_of": "2026-06-02T12:00:00+00:00",
+ "authority": "operator",
+ "content_hash": "blake3:signoff-cleared",
+ "disposition": "cleared",
+ "posture": "operator_signoff",
+ "reasons": [
+ "signoff_cleared"
+ ],
+ "sei": "loomweave:eid:0000000000000000000000000000aaaa"
+ },
+ {
+ "as_of": "2026-06-02T12:00:00+00:00",
+ "authority": "operator",
+ "content_hash": "blake3:operator-override",
+ "disposition": "cleared",
+ "posture": "protected_override",
+ "reasons": [
+ "operator_override"
+ ],
+ "sei": "loomweave:eid:0000000000000000000000000000aaaa"
+ }
+ ],
+ "sei": "loomweave:eid:0000000000000000000000000000aaaa",
+ "status": "checked"
+}
diff --git a/tests/conformance/test_governance_read_oracle.py b/tests/conformance/test_governance_read_oracle.py
new file mode 100644
index 0000000..1e2598f
--- /dev/null
+++ b/tests/conformance/test_governance_read_oracle.py
@@ -0,0 +1,216 @@
+"""Weft seam conformance oracle — legis governance_read (FROZEN GOLDEN).
+
+SEAM: legis produces per-SEI governance CLEARANCES via the ``governance_read``
+MCP tool (``_tool_governance_read`` -> ``legis.service.governance.read_governance_for_sei``).
+This module freezes a GOLDEN of legis's governance_read response and rechecks
+the REAL serializer + the REAL MCP wire against it, so a legis-side field rename
+or projection change reds here.
+
+GOLDEN PROVENANCE (non-circular): the golden was frozen ONCE from the real wire
+(ProtectedGate + SignoffGate writing genuine signed records into a real AuditStore,
+then call_tool("governance_read")). The rechecks below load the golden from the
+FILE and compare LIVE serializer/wire output to it — they never regenerate it.
+
+Mirrors test_warpline_attestation_oracle.py exactly. Key differences:
+ - tool is ``governance_read`` (not ``attestation_get``)
+ - golden field set is the clearance_record (sei/disposition/posture/authority/
+ as_of/reasons/content_hash), NOT attestation fields (kind/content_hash/seq)
+ - uses the same _build_attested_store / _SEI / _CLOCK_ISO / _KEY as the
+ attestation oracle so content_hashes are cross-checkable
+
+CONTENT_HASH CROSS-CHECK (test_content_hashes_match_attestation_golden): the
+clearance records' content_hash values must match the attestation golden's
+attestations' content_hash values (same records, different projection).
+
+Layered freshness defence:
+ * Layer-1 byte-pin (``test_governance_read_golden_byte_pin``): UNMARKED, recomputes
+ the git blob sha1 and fails CLOSED on any byte drift.
+ * Producer recheck (``test_mcp_wire_reproduces_golden``): drives real call_tool.
+ * Kind-coverage guard (``test_both_clearance_kinds_are_pinned``): golden must
+ carry both posture values.
+"""
+from __future__ import annotations
+
+import hashlib
+import json
+from functools import lru_cache
+from pathlib import Path
+
+import pytest
+
+from legis.clock import FixedClock
+from legis.enforcement.engine import EnforcementEngine
+from legis.enforcement.protected import ProtectedGate, TrailVerifier
+from legis.enforcement.signoff import SignoffGate
+from legis.enforcement.verdict import JudgeOpinion, Verdict
+from legis.identity.entity_key import EntityKey
+from legis.mcp import McpRuntime, call_tool
+
+# Reuse the SAME SEI, clock, key, and policy as the attestation oracle so that
+# content_hashes are identical between the two goldens (cross-check in test below).
+# If this oracle is run against the attestation oracle's store, the projection
+# must produce the SAME content_hash values.
+_CLOCK_ISO = "2026-06-02T12:00:00+00:00"
+_KEY = b"weft-seam-conformance-key"
+_POLICY = "protected.attestation"
+_SEI = "loomweave:eid:0000000000000000000000000000aaaa"
+
+GOLDEN_PATH = (
+ Path(__file__).parent / "fixtures" / "legis-governance-read.golden.json"
+)
+
+# git blob sha1 of the committed golden. Layer-1 fail-closed pin (UNMARKED).
+GOLDEN_BLOB_SHA = "898fd1a8c159cd717b0b976a5488b5c0c65a86a6"
+
+
+@lru_cache(maxsize=1)
+def _load_golden() -> dict:
+ return json.loads(GOLDEN_PATH.read_text(encoding="utf-8"))
+
+
+def _git_blob_sha1(data: bytes) -> str:
+ return hashlib.sha1(b"blob %d\0" % len(data) + data).hexdigest()
+
+
+class _AdvisoryJudge:
+ """Protected-cell judge is advisory; operator_override bypasses it entirely."""
+
+ def evaluate(self, record): # noqa: ANN001
+ return JudgeOpinion(Verdict.BLOCKED, "judge@1", "advisory only")
+
+
+def _build_attested_store(tmp_path) -> "AuditStore": # noqa: F821
+ """Write a genuine signoff_cleared + operator_override record, both keyed on _SEI.
+
+ IDENTICAL to test_warpline_attestation_oracle._build_attested_store (same
+ parameters, same key, same clock, same policy, same SEI) so the content_hash
+ values in the governance_read golden match the attestation golden.
+ """
+ from legis.store.audit_store import AuditStore
+
+ store = AuditStore(f"sqlite:///{tmp_path / 'gov.db'}")
+ clock = FixedClock(_CLOCK_ISO)
+
+ signoff = SignoffGate(store, clock, signer=True, key=_KEY)
+ req = signoff.request(
+ policy=_POLICY,
+ entity_key=EntityKey(value=_SEI, identity_stable=True),
+ rationale="review",
+ agent_id="agent-1",
+ extensions={"loomweave": {"content_hash": "blake3:signoff-cleared"}},
+ )
+ signoff.sign_off(request_seq=req.seq, operator_id="op-1", rationale="ok")
+
+ protected = ProtectedGate(
+ store,
+ clock,
+ judge=_AdvisoryJudge(),
+ key=_KEY,
+ protected_policies=frozenset({_POLICY}),
+ )
+ protected.operator_override(
+ policy=_POLICY,
+ entity_key=EntityKey(value=_SEI, identity_stable=True),
+ rationale="release exception approved by security lead",
+ operator_id="op-sec-lead",
+ file_fingerprint="sha256:abc",
+ ast_path="Module/Call[eval]",
+ extensions={"loomweave": {"content_hash": "blake3:operator-override"}},
+ )
+ return store
+
+
+def _wired_runtime(store) -> McpRuntime:
+ """A runtime with BOTH a protected gate and a trail verifier wired."""
+ from legis.store.audit_store import AuditStore # noqa: F401
+
+ engine = EnforcementEngine(store, FixedClock(_CLOCK_ISO))
+ gate = ProtectedGate(
+ store,
+ FixedClock(_CLOCK_ISO),
+ judge=_AdvisoryJudge(),
+ key=_KEY,
+ protected_policies=frozenset({_POLICY}),
+ )
+ return McpRuntime(
+ agent_id="agent-launch",
+ initialized=True,
+ engine=engine,
+ protected_gate=gate,
+ trail_verifier=TrailVerifier(_KEY, frozenset({_POLICY})),
+ )
+
+
+# --- Layer-1: byte-pin (UNMARKED, default suite) ---
+
+def test_governance_read_golden_byte_pin():
+ data = GOLDEN_PATH.read_bytes()
+ assert _git_blob_sha1(data) == GOLDEN_BLOB_SHA, (
+ "legis governance_read golden drifted from its pinned bytes; "
+ "re-freeze it from the real governance_read wire and update "
+ "GOLDEN_BLOB_SHA only after confirming the change is intended."
+ )
+
+
+# --- Producer recheck: real wire reproduces golden ---
+
+def test_mcp_wire_reproduces_golden(tmp_path):
+ """Drive the REAL call_tool wire. A rename of any clearance_record field
+ (sei/disposition/posture/authority/as_of/reasons/content_hash/status) reds here."""
+ store = _build_attested_store(tmp_path)
+ runtime = _wired_runtime(store)
+ result = call_tool(runtime, "governance_read", {"sei": _SEI})
+ assert not result.get("isError"), result
+ assert result["structuredContent"] == _load_golden()
+
+
+# --- Kind-coverage guard ---
+
+def test_both_clearance_kinds_are_pinned():
+ """The golden MUST carry both clearance postures (operator_signoff +
+ protected_override). A one-kind golden leaves the other projection branch
+ un-pinned and a rename there would not red here."""
+ postures = {r["posture"] for r in _load_golden()["records"]}
+ assert postures == {"operator_signoff", "protected_override"}
+
+
+# --- Content-hash cross-check with the attestation golden ---
+
+def test_content_hashes_match_attestation_golden():
+ """The governance_read golden's content_hash values must match the attestation
+ golden's content_hash values (same signed records, different projection).
+ If either golden is refreshed independently, this cross-check catches drift."""
+ att_golden_path = Path(__file__).parent / "fixtures" / "legis-warpline-attestation-get.golden.json"
+ att_golden = json.loads(att_golden_path.read_text(encoding="utf-8"))
+ att_hashes = {a["content_hash"] for a in att_golden["attestations"]}
+
+ gov_golden = _load_golden()
+ gov_hashes = {r["content_hash"] for r in gov_golden["records"]}
+
+ assert gov_hashes == att_hashes, (
+ "governance_read golden content_hashes don't match attestation golden — "
+ "they are built from the same store, so they must agree"
+ )
+
+
+# --- Rename-stability ---
+
+def test_governance_read_golden_is_keyed_on_sei():
+ golden = _load_golden()
+ assert golden["status"] == "checked"
+ assert golden["sei"] == _SEI
+ assert golden["sei"].startswith("loomweave:eid:")
+
+
+# --- Unavailable discriminant ---
+
+def test_unavailable_discriminant_is_distinct_from_empty_checked(tmp_path):
+ """Unwired gateway yields 'unavailable', not 'checked'/[] — the consumer
+ MUST distinguish these two statuses."""
+ store = _build_attested_store(tmp_path)
+ engine = EnforcementEngine(store, FixedClock(_CLOCK_ISO))
+ unwired = McpRuntime(agent_id="x", initialized=True, engine=engine)
+ sc = call_tool(unwired, "governance_read", {"sei": _SEI})["structuredContent"]
+ assert sc["status"] == "unavailable"
+ assert sc["records"] == []
+ assert sc["status"] != _load_golden()["status"]
diff --git a/tests/contract/test_governance_read_v1_schema.py b/tests/contract/test_governance_read_v1_schema.py
new file mode 100644
index 0000000..acf0e8f
--- /dev/null
+++ b/tests/contract/test_governance_read_v1_schema.py
@@ -0,0 +1,393 @@
+"""Contract freeze test for governance_read.v1.
+
+These tests pin the JSON schema at contracts/governance_read.v1.schema.json and the
+warpline hand-off prompt at docs/contracts/warpline-governance-read.v1-prompt.md.
+.v1 is a ONE-WAY DOOR — any change to the validation logic requires a .v2 schema and
+a new plan, never an in-place edit.
+
+TDD note: the format test (test_non_rfc3339_as_of_rejected) is the RED→GREEN signal for
+this task — it ONLY passes when jsonschema[format] (rfc3339-validator backend) is installed.
+
+Cross-transport schema agreement: the ``test_cross_transport_*`` tests each capture a REAL
+output (MCP golden / HTTP body / CLI stdout) from the respective transport and validate it
+against the committed frozen schema with the rfc3339 format checker wired. This ensures that
+all three adapters produce output that conforms to the same frozen contract.
+"""
+import json
+import pathlib
+import re
+
+import pytest
+from jsonschema import Draft202012Validator
+
+# ── paths ──────────────────────────────────────────────────────────────────────
+_REPO = pathlib.Path(__file__).parent.parent.parent # legis repo root
+_SCHEMA_PATH = _REPO / "contracts" / "governance_read.v1.schema.json"
+_PROMPT_PATH = _REPO / "docs" / "contracts" / "warpline-governance-read.v1-prompt.md"
+
+# ── fixtures ───────────────────────────────────────────────────────────────────
+@pytest.fixture(scope="module")
+def schema():
+ with open(_SCHEMA_PATH) as f:
+ return json.load(f)
+
+
+@pytest.fixture(scope="module")
+def validator(schema):
+ """Draft202012Validator WITH format checking (rfc3339-validator backend required)."""
+ return Draft202012Validator(schema, format_checker=Draft202012Validator.FORMAT_CHECKER)
+
+
+# Sample data ──────────────────────────────────────────────────────────────────
+_SEI = "loomweave:eid:7Q3f...c1"
+
+_RECORD_OVERRIDE = {
+ "sei": _SEI,
+ "disposition": "cleared",
+ "posture": "protected_override",
+ "authority": "operator",
+ "as_of": "2026-06-27T14:02:11Z",
+ "reasons": ["operator_override"],
+ "content_hash": "b3:9f2c...e7",
+}
+_RECORD_SIGNOFF = {
+ "sei": _SEI,
+ "disposition": "cleared",
+ "posture": "operator_signoff",
+ "authority": "operator",
+ "as_of": "2026-06-26T09:41:55Z",
+ "reasons": ["signoff_cleared"],
+ "content_hash": "b3:5a10...92",
+}
+
+
+# ── POSITIVE TESTS ─────────────────────────────────────────────────────────────
+
+def test_checked_two_clearances_valid(validator):
+ """Sample 1 from the warpline prompt: checked + two clearance records."""
+ doc = {"status": "checked", "sei": _SEI, "records": [_RECORD_OVERRIDE, _RECORD_SIGNOFF]}
+ validator.validate(doc)
+
+
+def test_checked_empty_records_valid(validator):
+ """Sample 2: checked + empty records (honest absence, not ungoverned)."""
+ doc = {"status": "checked", "sei": "loomweave:eid:unknown...", "records": []}
+ validator.validate(doc)
+
+
+def test_unavailable_with_reason_valid(validator):
+ """Sample 3: unavailable + reason array."""
+ doc = {
+ "status": "unavailable",
+ "sei": _SEI,
+ "records": [],
+ "unavailable": [{"reason": "trail not signature-verifiable (no protected gate / verifier)"}],
+ }
+ validator.validate(doc)
+
+
+# ── DISCRIMINATOR NEGATIVES ────────────────────────────────────────────────────
+# The schema enforces the discriminated union: status=unavailable REQUIRES the
+# `unavailable` key (non-empty) AND records:[] (maxItems 0); status=checked FORBIDS it.
+
+def test_unavailable_without_reason_rejected(validator):
+ """status:unavailable without the `unavailable` key is REJECTED (must-fix #5)."""
+ bad = {"status": "unavailable", "sei": _SEI, "records": []}
+ errors = list(validator.iter_errors(bad))
+ assert errors, "schema should have rejected unavailable without `unavailable` key"
+
+
+def test_unavailable_with_records_rejected(validator):
+ """status:unavailable with non-empty records is REJECTED (maxItems:0 enforced)."""
+ bad = {
+ "status": "unavailable",
+ "sei": _SEI,
+ "records": [_RECORD_OVERRIDE],
+ "unavailable": [{"reason": "some-reason"}],
+ }
+ errors = list(validator.iter_errors(bad))
+ assert errors, "schema should have rejected unavailable with non-empty records"
+
+
+def test_checked_with_unavailable_key_rejected(validator):
+ """status:checked with the `unavailable` key is REJECTED (not: required[unavailable])."""
+ bad = {
+ "status": "checked",
+ "sei": _SEI,
+ "records": [],
+ "unavailable": [{"reason": "this should not be here"}],
+ }
+ errors = list(validator.iter_errors(bad))
+ assert errors, "schema should have rejected checked with `unavailable` key present"
+
+
+# ── CONSTRAINT NEGATIVES ───────────────────────────────────────────────────────
+
+def test_unknown_status_rejected(validator):
+ bad = {"status": "unknown", "sei": _SEI, "records": []}
+ assert list(validator.iter_errors(bad))
+
+
+def test_record_extra_field_rejected(validator):
+ """clearance_record has additionalProperties:false."""
+ rec = {**_RECORD_OVERRIDE, "extra_field": "not-allowed"}
+ bad = {"status": "checked", "sei": _SEI, "records": [rec]}
+ assert list(validator.iter_errors(bad))
+
+
+def test_envelope_extra_field_rejected(validator):
+ """Envelope has additionalProperties:false."""
+ bad = {"status": "checked", "sei": _SEI, "records": [], "unexpected": "value"}
+ assert list(validator.iter_errors(bad))
+
+
+def test_empty_content_hash_rejected(validator):
+ """content_hash has minLength:1 — empty string is rejected."""
+ rec = {**_RECORD_OVERRIDE, "content_hash": ""}
+ bad = {"status": "checked", "sei": _SEI, "records": [rec]}
+ assert list(validator.iter_errors(bad))
+
+
+def test_empty_envelope_sei_rejected(validator):
+ """Envelope sei has minLength:1 — empty string is rejected."""
+ bad = {"status": "checked", "sei": "", "records": []}
+ assert list(validator.iter_errors(bad))
+
+
+def test_empty_record_sei_rejected(validator):
+ """clearance_record sei has minLength:1 — empty string is rejected."""
+ rec = {**_RECORD_OVERRIDE, "sei": ""}
+ bad = {"status": "checked", "sei": _SEI, "records": [rec]}
+ assert list(validator.iter_errors(bad))
+
+
+def test_empty_reasons_rejected(validator):
+ """reasons has minItems:1 — empty array is rejected."""
+ rec = {**_RECORD_OVERRIDE, "reasons": []}
+ bad = {"status": "checked", "sei": _SEI, "records": [rec]}
+ assert list(validator.iter_errors(bad))
+
+
+def test_invalid_posture_rejected(validator):
+ """posture is a closed enum; unknown value is rejected."""
+ rec = {**_RECORD_OVERRIDE, "posture": "chill"}
+ bad = {"status": "checked", "sei": _SEI, "records": [rec]}
+ assert list(validator.iter_errors(bad))
+
+
+# ── FORMAT TEST ────────────────────────────────────────────────────────────────
+
+def test_non_rfc3339_as_of_rejected(schema, validator):
+ """as_of: "not-a-date" must be rejected WHEN format_checker is wired.
+
+ The WITH-checker assertion is the RED→GREEN signal for jsonschema[format].
+ The WITHOUT-checker assertion proves the checker is load-bearing
+ (the format keyword alone does not validate without a backend).
+ """
+ rec = {**_RECORD_OVERRIDE, "as_of": "not-a-date"}
+ bad = {"status": "checked", "sei": _SEI, "records": [rec]}
+
+ # WITHOUT format_checker: format assertions are silently skipped → valid
+ validator_no_checker = Draft202012Validator(schema)
+ errors_without = list(validator_no_checker.iter_errors(bad))
+ assert not errors_without, (
+ "Without format_checker the schema should still accept 'not-a-date' (format not enforced); "
+ f"got errors: {errors_without}"
+ )
+
+ # WITH format_checker (rfc3339-validator backend required): must REJECT "not-a-date"
+ errors_with = list(validator.iter_errors(bad))
+ assert errors_with, (
+ "With format_checker the schema should reject as_of:'not-a-date' — "
+ "this assertion fails if rfc3339-validator is not installed (jsonschema[format] missing)"
+ )
+
+
+# ── DRIFT GUARD ────────────────────────────────────────────────────────────────
+
+def _strip_descriptions(obj):
+ """Recursively remove 'description' keys (non-validating annotations).
+
+ The committed schema carries descriptions on every property for human readers;
+ the warpline prompt carries an equivalent condensed block without them (the
+ prose provides the semantics). The drift guard compares STRUCTURAL content only.
+ Strip only 'description' — 'title' and all validating keywords are preserved so
+ that enum, type, minLength, format, allOf, etc. drift is still caught.
+ """
+ if isinstance(obj, dict):
+ return {k: _strip_descriptions(v) for k, v in obj.items() if k != "description"}
+ if isinstance(obj, list):
+ return [_strip_descriptions(item) for item in obj]
+ return obj
+
+
+def test_prompt_schema_block_matches_committed_file():
+ """The JSON block embedded in the warpline prompt must match the committed schema.
+
+ Structural (non-description) equality is asserted. If this test fails, the prompt
+ and the committed schema have diverged on a validating keyword — a genuine drift
+ that must be resolved (never buried by altering the comparison).
+
+ Note: the prompt header claims 'byte-equal' and the plan says 'JSON-equal'; the
+ only difference between the files is description annotations (non-validating).
+ Stripping descriptions before comparing keeps the guard strict on every keyword
+ that affects validation behaviour.
+ """
+ with open(_SCHEMA_PATH) as f:
+ committed = json.load(f)
+
+ with open(_PROMPT_PATH) as f:
+ content = f.read()
+
+ # Extract the first JSON fenced block under the "## The contract" heading
+ m = re.search(
+ r"## The contract.*?\n```json\n(.*?)\n```",
+ content,
+ re.DOTALL,
+ )
+ assert m, "Could not find a JSON fenced block under '## The contract' in the warpline prompt"
+ prompt_block = json.loads(m.group(1))
+
+ committed_stripped = _strip_descriptions(committed)
+ prompt_stripped = _strip_descriptions(prompt_block)
+ assert committed_stripped == prompt_stripped, (
+ "Structural drift detected between the committed schema and the prompt's embedded block. "
+ "Diff (committed keys only in descriptions are expected and ignored): resolve any "
+ "difference in validating keywords (type, enum, minLength, format, allOf, etc.) before "
+ "committing. A .v1 change is never allowed — use .v2."
+ )
+
+
+# ── CROSS-TRANSPORT SCHEMA AGREEMENT ──────────────────────────────────────────
+# Each test below captures a REAL output from one transport (MCP golden / HTTP /
+# CLI) and validates it against the committed frozen schema WITH the rfc3339
+# format checker wired. These are the only tests that couple the three adapters
+# to the single frozen contract file — not hand-authored shapes, not embedded
+# _one_of outputSchemas, but actual transport outputs.
+
+_GOLDEN_PATH = _REPO / "tests" / "conformance" / "fixtures" / "legis-governance-read.golden.json"
+
+# SEI / key / policy / clock mirroring test_governance_read_route.py for HTTP + CLI fixtures.
+_XPORT_CLOCK_ISO = "2026-06-02T12:00:00+00:00"
+_XPORT_KEY = b"xport-schema-key"
+_XPORT_KEY_STR = "xport-schema-key"
+_XPORT_POLICY = "protected.xport"
+_XPORT_SEI = "loomweave:eid:xport-schema-test"
+_XPORT_PROTECTED = frozenset({_XPORT_POLICY})
+
+
+def test_cross_transport_mcp_golden_validates_against_frozen_schema(validator):
+ """The Task 3 FROZEN GOLDEN (a real captured MCP wire output) must validate
+ against the frozen v1 schema WITH the rfc3339 format checker. This is the
+ canonical cross-transport check for the MCP adapter — it operates on an actual
+ captured output, not a hand-authored shape.
+ """
+ golden = json.loads(_GOLDEN_PATH.read_text(encoding="utf-8"))
+ # Must not raise — any schema violation here means the MCP adapter drifted from v1.
+ validator.validate(golden)
+
+
+def test_cross_transport_http_body_validates_against_frozen_schema(tmp_path, validator):
+ """A REAL HTTP response body from the FastAPI route must validate against the
+ frozen v1 schema. Drives the actual app (not a mock), so a route-level field
+ rename or missing key fails here immediately.
+ """
+ import hashlib
+
+ from fastapi.testclient import TestClient
+
+ from legis.api.app import create_app
+ from legis.clock import FixedClock
+ from legis.enforcement.engine import EnforcementEngine
+ from legis.enforcement.protected import ProtectedGate, TrailVerifier
+ from legis.enforcement.verdict import JudgeOpinion, Verdict
+ from legis.identity.entity_key import EntityKey
+ from legis.policy.cells import PolicyCellRegistry, PolicyCellRule
+ from legis.posture.ledger import PostureLedger
+ from legis.store.audit_store import AuditStore
+
+ class _Judge:
+ def evaluate(self, record): # noqa: ANN001
+ return JudgeOpinion(Verdict.BLOCKED, "j@1", "advisory")
+
+ store = AuditStore(f"sqlite:///{tmp_path / 'gov.db'}")
+ clock = FixedClock(_XPORT_CLOCK_ISO)
+ pg_write = ProtectedGate(store, clock, judge=_Judge(), key=_XPORT_KEY,
+ protected_policies=_XPORT_PROTECTED)
+ pg_write.operator_override(
+ policy=_XPORT_POLICY,
+ entity_key=EntityKey(value=_XPORT_SEI, identity_stable=True),
+ rationale="approved",
+ operator_id="op-xport",
+ file_fingerprint="sha256:ff",
+ ast_path="Module/Call",
+ extensions={"loomweave": {"content_hash": "blake3:xport-test"}},
+ )
+
+ ledger = PostureLedger(f"sqlite:///{tmp_path / 'posture.db'}", initialize=True)
+ fp = hashlib.sha256(b"k" * 32).hexdigest()
+ ledger.genesis(key_fingerprint=fp, agent_id="installer", recorded_at="t0")
+
+ app = create_app(
+ enforcement=EnforcementEngine(store, clock),
+ protected_gate=ProtectedGate(store, clock, judge=_Judge(), key=_XPORT_KEY,
+ protected_policies=_XPORT_PROTECTED),
+ trail_verifier=TrailVerifier(_XPORT_KEY, _XPORT_PROTECTED),
+ cell_registry=PolicyCellRegistry(
+ default_cell="chill",
+ rules=(PolicyCellRule(pattern=_XPORT_POLICY, cell="protected"),),
+ ),
+ posture_ledger=ledger,
+ )
+ import pytest as _pytest
+ _pytest.importorskip("fastapi") # defensive — already a dep
+ client = TestClient(app, headers={"Authorization": "Bearer dev-token"})
+ resp = client.get(f"/governance/sei/{_XPORT_SEI}/governance-read")
+ assert resp.status_code == 200
+ body = resp.json()
+ # The core assertion: the REAL HTTP output validates against the frozen contract.
+ validator.validate(body)
+
+
+def test_cross_transport_cli_stdout_validates_against_frozen_schema(
+ tmp_path, monkeypatch, capsys, validator
+):
+ """A REAL CLI stdout from `legis governance-read` must validate against the
+ frozen v1 schema WITH the rfc3339 format checker. Captures actual main() output
+ so any CLI serialization drift from the v1 envelope shape fails here.
+ """
+ from legis.cli import main
+ from legis.clock import FixedClock
+ from legis.enforcement.protected import ProtectedGate
+ from legis.enforcement.verdict import JudgeOpinion, Verdict
+ from legis.identity.entity_key import EntityKey
+ from legis.store.audit_store import AuditStore
+
+ class _AcceptJudge:
+ def evaluate(self, record): # noqa: ANN001
+ return JudgeOpinion(verdict=Verdict.ACCEPTED, model="j@1", rationale="ok")
+
+ db_path = tmp_path / "gov.db"
+ store = AuditStore(f"sqlite:///{db_path}")
+ gate = ProtectedGate(store, FixedClock(_XPORT_CLOCK_ISO), judge=_AcceptJudge(),
+ key=_XPORT_KEY, protected_policies=_XPORT_PROTECTED)
+ gate.operator_override(
+ policy=_XPORT_POLICY,
+ entity_key=EntityKey(value=_XPORT_SEI, identity_stable=True),
+ rationale="operator clears",
+ operator_id="op-cli-xport",
+ file_fingerprint="sha256:ff",
+ ast_path="Module.Call",
+ extensions={"loomweave": {"content_hash": "ch:cli-xport-test"}},
+ )
+
+ monkeypatch.setenv("LEGIS_HMAC_KEY", _XPORT_KEY_STR)
+ monkeypatch.setenv("LEGIS_PROTECTED_POLICIES", _XPORT_POLICY)
+
+ rc = main(["governance-read", "--db", f"sqlite:///{db_path}", _XPORT_SEI])
+ assert rc == 0
+ out, err = capsys.readouterr()
+ assert not err, f"unexpected stderr: {err!r}"
+ envelope = json.loads(out)
+ # The core assertion: the REAL CLI output validates against the frozen contract.
+ validator.validate(envelope)
diff --git a/tests/enforcement/test_protected_transaction.py b/tests/enforcement/test_protected_transaction.py
new file mode 100644
index 0000000..f25f9ef
--- /dev/null
+++ b/tests/enforcement/test_protected_transaction.py
@@ -0,0 +1,141 @@
+import sqlite3
+
+import pytest
+
+from legis.clock import FixedClock
+from legis.enforcement.protected import ProtectedGate
+from legis.identity.entity_key import EntityKey
+from legis.store.audit_store import AuditStore
+from legis.store.head_anchor import AnchorError, HeadAnchor
+
+KEY = b"k" * 32
+CLOCK = "2026-06-26T12:00:00+00:00"
+
+
+class _UnusedJudge:
+ """operator_override bypasses the judge; if it is consulted, fail loudly."""
+
+ def evaluate(self, record): # pragma: no cover - must never be called
+ raise AssertionError("judge must not be consulted on operator_override")
+
+
+def _anchored_gate(tmp_path):
+ store = AuditStore(f"sqlite:///{tmp_path / 'gov.db'}")
+ anchor = HeadAnchor(str(tmp_path / "gov.anchor"), KEY)
+ gate = ProtectedGate(store, FixedClock(CLOCK), _UnusedJudge(), KEY, anchor=anchor)
+ return store, anchor, gate
+
+
+def _override(gate, rationale):
+ return gate.operator_override(
+ policy="protected/secrets",
+ entity_key=EntityKey.from_locator("m.f"),
+ rationale=rationale,
+ operator_id="op",
+ file_fingerprint="fp",
+ ast_path="m.f",
+ )
+
+
+def _truncate_to(db_path, keep_seq):
+ """Raw out-of-band tail truncation (drops the append-only triggers first).
+
+ No survivor re-chain needed: AnchorError fires on the head_seq comparison
+ before chain_hash is checked.
+ """
+ con = sqlite3.connect(db_path)
+ try:
+ con.execute("DROP TRIGGER IF EXISTS audit_log_no_update")
+ con.execute("DROP TRIGGER IF EXISTS audit_log_no_delete")
+ con.execute("DELETE FROM audit_log WHERE seq > ?", (keep_seq,))
+ con.commit()
+ finally:
+ con.close()
+
+
+def test_protected_transaction_advances_anchor_so_truncation_is_detected(tmp_path):
+ """A protected append batched through ProtectedGate.transaction() advances the
+ HeadAnchor after commit, so a later tail-truncation back to the pre-batch head
+ is DETECTED (parity with SignoffGate.transaction()). legis-0c310712a7.
+ """
+ store, anchor, gate = _anchored_gate(tmp_path)
+ # A pre-batch protected append (outside any batch -> anchor advances normally).
+ _override(gate, "pre-batch")
+ pre_seq, _ = store.get_latest_sequence_and_hash()
+
+ # Batch a protected append through the NEW owned transaction API.
+ with gate.transaction():
+ _override(gate, "in-batch")
+
+ # The transaction() advanced the anchor to the in-batch record's head.
+ head_seq, _ = store.get_latest_sequence_and_hash()
+ assert head_seq == pre_seq + 1
+
+ # Truncate the in-batch record out of band, back to the pre-batch head.
+ _truncate_to(str(tmp_path / "gov.db"), pre_seq)
+
+ # The anchor remembers the higher head -> truncation is detected.
+ with pytest.raises(AnchorError):
+ anchor.check(store.read_all())
+
+
+def test_protected_transaction_is_safe_without_an_anchor(tmp_path):
+ """Production default: anchor=None. transaction() still batches atomically;
+ the if-anchor guard is a no-op, not a crash."""
+ store = AuditStore(f"sqlite:///{tmp_path / 'gov.db'}")
+ gate = ProtectedGate(store, FixedClock(CLOCK), _UnusedJudge(), KEY) # no anchor=
+ with gate.transaction():
+ _override(gate, "in-batch")
+ assert len(store.read_all()) == 1
+ assert store.verify_integrity() is True
+
+
+def test_protected_transaction_no_overshoot_on_rollback(tmp_path):
+ """AUD-1 no-overshoot: an exception inside gate.transaction() rolls back the
+ append AND the anchor update, so the anchor never advances past a rolled-back
+ head. legis-0c310712a7."""
+ store, anchor, gate = _anchored_gate(tmp_path)
+ # Pre-batch append: anchor lands at a known head.
+ _override(gate, "pre-rollback")
+ pre_batch_count = len(store.read_all())
+
+ with pytest.raises(RuntimeError):
+ with gate.transaction():
+ _override(gate, "will-be-rolled-back")
+ raise RuntimeError("simulated failure inside batch")
+
+ # Rollback dropped the in-batch row; anchor update never ran.
+ assert len(store.read_all()) == pre_batch_count
+ # Anchor still at the pre-batch head -> does not raise.
+ anchor.check(store.read_all())
+
+
+def test_raw_store_transaction_bypasses_anchor_advance_documented_residual(tmp_path):
+ """DOCUMENTS the parity limit (not a defense): a protected append inside a RAW
+ store.transaction() — bypassing gate.transaction() — defers the anchor advance
+ (in_batch() is true) and nothing re-advances it, so a truncation back to the
+ pre-batch head is NOT detected. The supported safe path is gate.transaction()
+ (Task 1); a caller that owns a raw batch must advance the anchor itself,
+ identically to SignoffGate. If a future change closes this (e.g. an after-commit
+ hook on AuditStore.transaction), update this test deliberately. legis-0c310712a7.
+ """
+ store, anchor, gate = _anchored_gate(tmp_path)
+ _override(gate, "pre-batch")
+ pre_seq, _ = store.get_latest_sequence_and_hash()
+
+ # RAW batch (NOT gate.transaction()): the append's anchor advance is deferred
+ # and never re-applied.
+ with store.transaction():
+ _override(gate, "in-raw-batch")
+
+ # LANDING ASSERTION: proves the in-raw-batch append actually landed (the
+ # in_batch()-deferred branch was taken). If this fails, the raw batch silently
+ # no-op'd and the residual test cannot be trusted. Do NOT silence by weakening
+ # the assertion.
+ assert store.get_latest_sequence_and_hash()[0] == pre_seq + 1
+
+ # The anchor is STALE at the pre-batch head (the residual).
+ _truncate_to(str(tmp_path / "gov.db"), pre_seq)
+
+ # Stale anchor == truncated DB head -> truncation is NOT detected.
+ anchor.check(store.read_all()) # does not raise: asserts the DOCUMENTED residual
diff --git a/tests/mcp/test_governance_read_tool.py b/tests/mcp/test_governance_read_tool.py
new file mode 100644
index 0000000..593ef76
--- /dev/null
+++ b/tests/mcp/test_governance_read_tool.py
@@ -0,0 +1,189 @@
+"""MCP tool ``governance_read`` handler tests.
+
+Mirrors the attestation_get test structure (tests/mcp/test_server.py Task 5 section):
+ (a) verifiable trail with a clearance -> {status:checked, records:[…]}
+ (b) protected_gate is None or trail_verifier is None -> {status:unavailable, …}
+ (c) tampered trail -> AuditIntegrityError -> AUDIT_INTEGRITY_FAILURE error envelope
+
+The BOTH-objects fail-closed pre-gate (must-fix) is the key invariant: the tool
+must gate on *both* runtime.protected_gate AND runtime.trail_verifier, not just one.
+"""
+from __future__ import annotations
+
+from legis.clock import FixedClock
+from legis.enforcement.engine import EnforcementEngine
+from legis.enforcement.protected import ProtectedGate, TrailVerifier
+from legis.enforcement.signoff import SignoffGate
+from legis.enforcement.verdict import JudgeOpinion, Verdict
+from legis.identity.entity_key import EntityKey
+from legis.mcp import McpRuntime, call_tool
+from legis.store.audit_store import AuditStore
+
+_CLOCK_ISO = "2026-06-02T12:00:00+00:00"
+_KEY = b"gov-read-key"
+_POLICY = "protected.governance"
+_SEI = "loomweave:eid:governance-read-test"
+
+
+class _AdvisoryJudge:
+ def evaluate(self, record): # noqa: ANN001
+ return JudgeOpinion(Verdict.BLOCKED, "judge@1", "advisory only")
+
+
+def _runtime(tmp_path):
+ store = AuditStore(f"sqlite:///{tmp_path / 'gov.db'}")
+ engine = EnforcementEngine(store, FixedClock(_CLOCK_ISO))
+ return McpRuntime(agent_id="agent-launch", initialized=True, engine=engine), store
+
+
+def _wired_runtime_with_clearance(tmp_path):
+ """Build a runtime with BOTH objects wired and a genuine clearance (signoff_cleared +
+ operator_override) for _SEI."""
+ store = AuditStore(f"sqlite:///{tmp_path / 'gov.db'}")
+ clock = FixedClock(_CLOCK_ISO)
+
+ # Write a signoff_cleared record
+ gate = SignoffGate(store, clock, signer=True, key=_KEY)
+ req = gate.request(
+ policy=_POLICY,
+ entity_key=EntityKey(value=_SEI, identity_stable=True),
+ rationale="review",
+ agent_id="agent-1",
+ extensions={"loomweave": {"content_hash": "blake3:clearance-signoff"}},
+ )
+ gate.sign_off(request_seq=req.seq, operator_id="op-1", rationale="ok")
+
+ # Write an operator_override record
+ protected = ProtectedGate(
+ store,
+ clock,
+ judge=_AdvisoryJudge(),
+ key=_KEY,
+ protected_policies=frozenset({_POLICY}),
+ )
+ protected.operator_override(
+ policy=_POLICY,
+ entity_key=EntityKey(value=_SEI, identity_stable=True),
+ rationale="approved",
+ operator_id="op-sec",
+ file_fingerprint="sha256:abc",
+ ast_path="Module/Call",
+ extensions={"loomweave": {"content_hash": "blake3:clearance-override"}},
+ )
+
+ engine = EnforcementEngine(store, clock)
+ pg = ProtectedGate(
+ store, clock, judge=_AdvisoryJudge(), key=_KEY,
+ protected_policies=frozenset({_POLICY}),
+ )
+ runtime = McpRuntime(
+ agent_id="agent-launch",
+ initialized=True,
+ engine=engine,
+ protected_gate=pg,
+ trail_verifier=TrailVerifier(_KEY, frozenset({_POLICY})),
+ )
+ return runtime, store
+
+
+# --- (a) verifiable trail with clearance -> checked with records ---
+
+def test_governance_read_checked_with_clearances(tmp_path):
+ runtime, _store = _wired_runtime_with_clearance(tmp_path)
+ result = call_tool(runtime, "governance_read", {"sei": _SEI})
+ assert not result.get("isError"), result
+ sc = result["structuredContent"]
+ assert sc["status"] == "checked"
+ assert sc["sei"] == _SEI
+ assert len(sc["records"]) == 2
+ # Both clearance kinds must appear
+ postures = {r["posture"] for r in sc["records"]}
+ assert postures == {"operator_signoff", "protected_override"}
+ # Every record must have the required v1 clearance_record fields
+ for rec in sc["records"]:
+ assert rec["disposition"] == "cleared"
+ assert rec["authority"] == "operator"
+ assert rec["sei"] == _SEI
+ assert isinstance(rec["reasons"], list) and len(rec["reasons"]) == 1
+ assert rec["content_hash"].startswith("blake3:")
+ assert rec["as_of"] == _CLOCK_ISO
+
+
+def test_governance_read_empty_verified_trail_is_checked_empty(tmp_path):
+ """A wired runtime with no clearances returns status=checked, records=[]
+ (honest absence, NOT unavailable — trail was checked)."""
+ runtime, _store = _runtime(tmp_path)
+
+ class _OkVerifier:
+ def verify(self, records):
+ return None
+
+ class _FakeProtectedGate:
+ def records(self):
+ return []
+
+ runtime.protected_gate = _FakeProtectedGate()
+ runtime.trail_verifier = _OkVerifier()
+ result = call_tool(runtime, "governance_read", {"sei": _SEI})
+ assert not result.get("isError"), result
+ sc = result["structuredContent"]
+ assert sc["status"] == "checked"
+ assert sc["records"] == []
+ assert "unavailable" not in sc
+
+
+# --- (b) both-objects fail-closed pre-gate ---
+
+def test_governance_read_unavailable_when_no_protected_gate(tmp_path):
+ """No protected_gate (engine-only deployment) -> unavailable, NEVER checked/[]."""
+ runtime, _store = _runtime(tmp_path)
+ assert runtime.protected_gate is None
+ result = call_tool(runtime, "governance_read", {"sei": _SEI})
+ assert not result.get("isError"), result
+ sc = result["structuredContent"]
+ assert sc["status"] == "unavailable"
+ assert sc["sei"] == _SEI
+ assert sc["records"] == []
+ assert sc["unavailable"] and "reason" in sc["unavailable"][0]
+
+
+def test_governance_read_unavailable_when_no_trail_verifier(tmp_path):
+ """protected_gate present but trail_verifier absent -> unavailable.
+ The BOTH-objects gate must reject on either missing half."""
+ runtime, _store = _runtime(tmp_path)
+
+ class _FakeProtectedGate:
+ def records(self):
+ return []
+
+ runtime.protected_gate = _FakeProtectedGate()
+ # trail_verifier deliberately left None
+ assert runtime.trail_verifier is None
+ result = call_tool(runtime, "governance_read", {"sei": _SEI})
+ assert not result.get("isError"), result
+ sc = result["structuredContent"]
+ assert sc["status"] == "unavailable"
+ assert sc["records"] == []
+ assert sc["unavailable"] and "reason" in sc["unavailable"][0]
+
+
+# --- (c) tampered trail -> AUDIT_INTEGRITY_FAILURE ---
+
+def test_governance_read_tamper_yields_audit_integrity_failure(tmp_path):
+ """A tampered trail -> AuditIntegrityError -> AUDIT_INTEGRITY_FAILURE error envelope."""
+ runtime, _store = _runtime(tmp_path)
+
+ class _TamperVerifier:
+ def verify(self, records):
+ from legis.enforcement.protected import TamperError
+ raise TamperError("record 2 hash mismatch")
+
+ class _FakeProtectedGate:
+ def records(self):
+ return ["bad-record"]
+
+ runtime.protected_gate = _FakeProtectedGate()
+ runtime.trail_verifier = _TamperVerifier()
+ result = call_tool(runtime, "governance_read", {"sei": _SEI})
+ assert result.get("isError")
+ assert result["structuredContent"]["error_code"] == "AUDIT_INTEGRITY_FAILURE"
diff --git a/tests/mcp/test_output_schema_conformance.py b/tests/mcp/test_output_schema_conformance.py
index 457d7a5..65a0dda 100644
--- a/tests/mcp/test_output_schema_conformance.py
+++ b/tests/mcp/test_output_schema_conformance.py
@@ -632,11 +632,27 @@ def test_warpline_preflight_get_unavailable_conforms(tmp_path):
def test_warpline_preflight_get_checked_conforms(tmp_path):
class _FakeWarpline:
+ """Returns real-shaped envelopes with a GV-LG-3-valid meta.
+
+ A meta-violating envelope would be refused → unavailable, which would
+ make the ``status == 'checked'`` assertion below fail silently.
+ """
+
def impact_radius(self, base, head):
- return {"affected": [], "count": 0}
+ return {
+ "schema": "warpline.impact_radius.v1",
+ "ok": True,
+ "data": {"completeness": "FULL", "affected": []},
+ "meta": {"local_only": True, "peer_side_effects": []},
+ }
def reverify_worklist(self, base, head):
- return {"entries": [], "count": 0}
+ return {
+ "schema": "warpline.reverify_worklist.v1",
+ "ok": True,
+ "data": {"completeness": "FULL", "items": []},
+ "meta": {"local_only": True, "peer_side_effects": []},
+ }
runtime, _store = _runtime(tmp_path)
runtime.warpline = _FakeWarpline()
@@ -644,6 +660,43 @@ def reverify_worklist(self, base, head):
assert payload["status"] == "checked"
+def test_plainweave_preflight_get_unavailable_conforms(tmp_path):
+ runtime, _store = _runtime(tmp_path) # plainweave None
+ payload = _conformant(runtime, "plainweave_preflight_get", {"base": "aaa"})
+ assert payload["status"] == "unavailable"
+
+
+def test_plainweave_preflight_get_checked_conforms(tmp_path):
+ class _FakePlainweave:
+ """Returns a real-shaped envelope with a GV-LG-3-valid authority_boundary.
+
+ A boundary-violating envelope would be refused → unavailable, which would
+ make the ``status == 'checked'`` assertion below fail silently.
+ """
+
+ def preflight_facts(self, base, head):
+ return {
+ "schema": "weft.plainweave.preflight_facts.v1",
+ "ok": True,
+ "data": {
+ "freshness": "partial",
+ "facts": [],
+ "authority_boundary": {
+ "local_only": True,
+ "live_peer_calls": False,
+ "governance_verdicts": False,
+ },
+ },
+ "warnings": [],
+ "meta": {},
+ }
+
+ runtime, _store = _runtime(tmp_path)
+ runtime.plainweave = _FakePlainweave()
+ payload = _conformant(runtime, "plainweave_preflight_get", {"base": "aaa", "head": "bbb"})
+ assert payload["status"] == "checked"
+
+
def test_attestation_get_unavailable_conforms(tmp_path):
runtime, _store = _runtime(tmp_path) # no protected gate
payload = _conformant(runtime, "attestation_get", {"sei": "mod.fn#1"})
@@ -671,3 +724,59 @@ def records(self):
result = call_tool(runtime, "attestation_get", {"sei": "mod.fn#1"})
assert result.get("isError")
Draft202012Validator(ERROR_ENVELOPE_SCHEMA).validate(result["structuredContent"])
+
+
+# --- governance_read output schema conformance ---
+
+
+def test_governance_read_checked_variant_conforms(tmp_path):
+ """The 'checked' variant of governance_read with actual clearance records
+ must validate against the declared _one_of outputSchema."""
+ from legis.clock import FixedClock
+ from legis.enforcement.protected import ProtectedGate, TrailVerifier
+ from legis.enforcement.signoff import SignoffGate
+ from legis.identity.entity_key import EntityKey
+
+ _KEY = b"schema-conf-key"
+ _POLICY = "protected.schema_test"
+ _SEI = "loomweave:eid:schema-conformance"
+
+ store = AuditStore(f"sqlite:///{tmp_path / 'gov-conf.db'}")
+ clock = FixedClock("2026-06-02T12:00:00+00:00")
+
+ # Write a genuine signoff_cleared record
+ gate = SignoffGate(store, clock, signer=True, key=_KEY)
+ req = gate.request(
+ policy=_POLICY,
+ entity_key=EntityKey(value=_SEI, identity_stable=True),
+ rationale="review",
+ agent_id="agent-1",
+ extensions={"loomweave": {"content_hash": "blake3:schema-conf"}},
+ )
+ gate.sign_off(request_seq=req.seq, operator_id="op-1", rationale="ok")
+
+ runtime, _ = _runtime(tmp_path)
+ runtime.protected_gate = ProtectedGate(
+ store, clock, judge=_ScriptedJudge(), key=_KEY,
+ protected_policies=frozenset({_POLICY}),
+ )
+ runtime.trail_verifier = TrailVerifier(_KEY, frozenset({_POLICY}))
+ runtime.engine._store = store # point engine at the store with clearances
+
+ payload = _conformant(runtime, "governance_read", {"sei": _SEI})
+ assert payload["status"] == "checked"
+ assert len(payload["records"]) >= 1
+ rec = payload["records"][0]
+ assert rec["disposition"] == "cleared"
+ assert rec["posture"] == "operator_signoff"
+ assert rec["authority"] == "operator"
+
+
+def test_governance_read_unavailable_variant_conforms(tmp_path):
+ """The 'unavailable' variant (no protected gate wired) must validate against
+ the declared _one_of outputSchema."""
+ runtime, _store = _runtime(tmp_path) # no protected gate
+ payload = _conformant(runtime, "governance_read", {"sei": "loomweave:eid:any"})
+ assert payload["status"] == "unavailable"
+ assert payload["records"] == []
+ assert payload["unavailable"] and "reason" in payload["unavailable"][0]
diff --git a/tests/mcp/test_plainweave_advisory_boundary.py b/tests/mcp/test_plainweave_advisory_boundary.py
new file mode 100644
index 0000000..fc29cfe
--- /dev/null
+++ b/tests/mcp/test_plainweave_advisory_boundary.py
@@ -0,0 +1,230 @@
+"""Byte-identical advisory-boundary acceptance spine — plainweave sibling.
+
+Proves the governance-verdict invariant for the plainweave advisory read: verdicts
+are byte-identical whether ``runtime.plainweave`` is None or points to a hostile
+advisory client returning arbitrary garbage facts. The structural companion test
+additionally asserts that ``runtime.plainweave`` is referenced in no verdict-path
+function source, giving defense-in-depth coverage.
+
+If either test fails, plainweave data has somehow reached a verdict path —
+a security regression. Mirrors ``tests/mcp/test_warpline_advisory_boundary.py``.
+"""
+
+import inspect
+import json
+
+from legis.clock import FixedClock
+from legis.enforcement.engine import EnforcementEngine
+from legis.plainweave_preflight.client import PlainweaveMcpClient
+from legis.policy.grammar import AllowlistBoundary, PolicyGrammar
+from legis.store.audit_store import AuditStore
+
+
+# ---------------------------------------------------------------------------
+# Local helpers (mirrors tests/mcp/test_warpline_advisory_boundary.py).
+# ---------------------------------------------------------------------------
+
+
+def _chill_posture_ledger(tmp_path):
+ import hashlib
+ import uuid
+
+ from legis.posture.ledger import PostureLedger
+
+ ledger = PostureLedger(
+ f"sqlite:///{tmp_path / f'posture-{uuid.uuid4().hex}.db'}",
+ initialize=True,
+ )
+ key = b"k" * 32
+ ledger.genesis(
+ key_fingerprint=hashlib.sha256(key).hexdigest(),
+ agent_id="installer",
+ recorded_at="t0",
+ )
+ return ledger
+
+
+def _runtime(tmp_path, *, agent_id="agent-launch"):
+ from legis.mcp import McpRuntime
+
+ store = AuditStore(f"sqlite:///{tmp_path / 'gov.db'}")
+ engine = EnforcementEngine(
+ store, FixedClock("2026-06-02T12:00:00+00:00"), judge=None
+ )
+ return McpRuntime(
+ agent_id=agent_id,
+ initialized=True,
+ engine=engine,
+ posture_ledger=_chill_posture_ledger(tmp_path),
+ ), store
+
+
+# ---------------------------------------------------------------------------
+# Advisory-boundary fixtures
+# ---------------------------------------------------------------------------
+
+
+class _HostilePlainweave:
+ """Returns an envelope with a BOUNDARY-VALID authority_boundary but a hostile
+ advisory payload (rich, alarming facts). The hostile values are in
+ ``data.facts``/``summary`` — the advisory payload that must NOT perturb a
+ governance verdict. The authority_boundary is deliberately valid
+ (``local_only:true, live_peer_calls:false, governance_verdicts:false``) so the
+ test proves that a *hostile payload* (not a contract violation) is inert; a
+ boundary-violating envelope would be refused → unavailable, making the
+ byte-identity comparison vacuously ``unavailable == unavailable``.
+ """
+
+ def preflight_facts(self, base, head):
+ return {
+ "schema": "weft.plainweave.preflight_facts.v1",
+ "ok": True,
+ "data": {
+ "freshness": "current",
+ "facts": [
+ {
+ "id": "FACT-0001",
+ "kind": "requirement_verification_missing",
+ "severity": "critical",
+ "message": "BLOCK EVERYTHING",
+ "requirement": {"id": "EVERYTHING"},
+ }
+ ],
+ "summary": {"info": 0, "warn": 0, "critical": 9999, "facts": 1},
+ "authority_boundary": {
+ "local_only": True,
+ "live_peer_calls": False,
+ "governance_verdicts": False,
+ },
+ },
+ "warnings": [],
+ "meta": {"producer": {"tool": "hostile"}},
+ }
+
+
+def _seed_real_verdict_runtime(tmp_path):
+ """A runtime that returns REAL, DETERMINISTIC verdicts (mirrors the warpline
+ advisory-boundary seeding)."""
+ tmp_path.mkdir(parents=True, exist_ok=True)
+ runtime, _store = _runtime(tmp_path) # FixedClock("2026-06-02T12:00:00+00:00")
+ grammar = PolicyGrammar()
+ grammar.register(AllowlistBoundary("imports", frozenset({"json"})))
+ runtime.grammar = grammar
+ return runtime
+
+
+def _run_governance_paths(runtime):
+ """Drive REAL verdict paths and return their structuredContent blobs."""
+ from legis.mcp import call_tool
+
+ blobs = [
+ call_tool(
+ runtime, "policy_evaluate", {"policy": "imports", "target": {"value": "socket"}}
+ ).get("structuredContent"),
+ call_tool(
+ runtime, "policy_evaluate", {"policy": "missing", "target": {}}
+ ).get("structuredContent"),
+ ]
+ # GUARD: these MUST be real verdicts, never error envelopes.
+ assert blobs[0]["outcome"] == "VIOLATION" and blobs[0]["provenance_gap"] is False
+ assert blobs[1]["outcome"] == "UNKNOWN" and blobs[1]["provenance_gap"] is True
+ return blobs
+
+
+# ---------------------------------------------------------------------------
+# Tests
+# ---------------------------------------------------------------------------
+
+
+def test_governance_verdicts_byte_identical_plainweave_unset_vs_hostile(tmp_path):
+ # Everything is held IDENTICAL across the two runtimes EXCEPT runtime.plainweave.
+ # If plainweave data could reach a verdict path, the hostile side would diverge.
+ runtime_unset = _seed_real_verdict_runtime(tmp_path / "a")
+ runtime_unset.plainweave = None
+ unset = _run_governance_paths(runtime_unset)
+
+ runtime_set = _seed_real_verdict_runtime(tmp_path / "b")
+ runtime_set.plainweave = _HostilePlainweave() # structurally present, hostile
+ setval = _run_governance_paths(runtime_set)
+
+ # GUARD: the hostile side must have actually reached status=="checked" — a
+ # _HostilePlainweave that produced 'unavailable' would make the byte-identity
+ # assertion trivially pass while proving nothing.
+ from legis.mcp import call_tool
+ pf = call_tool(runtime_set, "plainweave_preflight_get", {"base": "aaa", "head": "bbb"})
+ assert pf["structuredContent"]["status"] == "checked", (
+ "_HostilePlainweave returned unavailable — its envelope was rejected before "
+ "reaching the advisory layer; the byte-identity assertion is vacuous"
+ )
+
+ assert json.dumps(unset, sort_keys=True) == json.dumps(setval, sort_keys=True)
+
+
+def test_gv_lg_3_governance_verdicts_true_yields_unavailable(tmp_path):
+ """Positive GV-LG-3 pin: an envelope whose authority_boundary claims to emit
+ governance verdicts is refused by PlainweaveMcpClient._call (raises
+ PlainweaveError), which propagates through read_plainweave_preflight as
+ status='unavailable'. ADR-006's whole point is that plainweave emits facts
+ only — a producer claiming verdicts is a contract violation, refused."""
+ from legis.mcp import call_tool
+
+ verdict_claiming_envelope = {
+ "schema": "weft.plainweave.preflight_facts.v1",
+ "ok": True,
+ "data": {
+ "freshness": "partial",
+ "facts": [],
+ "authority_boundary": {
+ "local_only": True,
+ "live_peer_calls": False,
+ "governance_verdicts": True, # GV-LG-3 VIOLATION
+ },
+ },
+ "warnings": [],
+ "meta": {},
+ }
+ runtime, _ = _runtime(tmp_path)
+ runtime.plainweave = PlainweaveMcpClient(
+ invoke=lambda t, a: verdict_claiming_envelope, repo="/r"
+ )
+ pf = call_tool(runtime, "plainweave_preflight_get", {"base": "aaa", "head": "bbb"})
+ assert pf["structuredContent"]["status"] == "unavailable", (
+ "GV-LG-3: a producer claiming governance_verdicts must yield unavailable, not checked"
+ )
+
+
+def test_runtime_plainweave_referenced_in_no_verdict_path_function():
+ # STRUCTURAL (defense-in-depth): runtime.plainweave must appear in NO
+ # verdict-path / honesty-read source. Tool-handler coverage is DERIVED from
+ # _TOOL_HANDLERS so any future handler is covered by construction; the single
+ # legitimate advisory handler (_tool_plainweave_preflight_get) is excluded by
+ # name — any other handler that starts reading .plainweave fails immediately.
+ import legis.mcp as mcp
+ from legis.service.governance import read_sei_attestations
+
+ _PLAINWEAVE_HANDLER = "_tool_plainweave_preflight_get"
+ for name, handler in mcp._TOOL_HANDLERS.items():
+ if handler.__name__ == _PLAINWEAVE_HANDLER:
+ continue
+ src = inspect.getsource(handler)
+ assert ".plainweave" not in src, (
+ f"tool handler {handler.__name__!r} (tool={name!r}) references plainweave"
+ )
+
+ from legis.service.governance import (
+ governance_read_unavailable,
+ read_governance_for_sei,
+ read_governance_for_sei_gate,
+ )
+
+ for fn in [
+ mcp._engine,
+ mcp._coached_engine,
+ mcp._governance_trail_records,
+ read_sei_attestations,
+ read_governance_for_sei,
+ read_governance_for_sei_gate,
+ governance_read_unavailable,
+ ]:
+ src = inspect.getsource(fn)
+ assert ".plainweave" not in src, f"{fn.__name__} references plainweave"
diff --git a/tests/mcp/test_server.py b/tests/mcp/test_server.py
index 581430a..e703051 100644
--- a/tests/mcp/test_server.py
+++ b/tests/mcp/test_server.py
@@ -303,7 +303,9 @@ def test_initialize_and_tools_list_exposes_full_agent_surface(tmp_path):
"policy_boundary_check",
"posture_get",
"warpline_preflight_get",
+ "plainweave_preflight_get",
"attestation_get",
+ "governance_read",
}
# posture_get is the dedicated read-only posture surface (Phase 8); the
# change gate (posture set) stays operator/CLI only — no posture_set tool.
@@ -2131,6 +2133,7 @@ def test_warpline_tools_introduce_no_new_error_codes(tmp_path):
runtime, _store = _runtime(tmp_path)
assert not call_tool(runtime, "warpline_preflight_get", {"base": "x"}).get("isError")
+ assert not call_tool(runtime, "plainweave_preflight_get", {"base": "x"}).get("isError")
assert not call_tool(runtime, "attestation_get", {"sei": "x#1"}).get("isError")
@@ -3385,33 +3388,33 @@ def test_check_list_target_type_schema_declares_enum_matching_handler(tmp_path):
def test_build_runtime_wires_warpline_from_env(monkeypatch, tmp_path):
from legis.mcp import build_runtime
- from legis.warpline_preflight.client import HttpWarplineClient
+ from legis.warpline_preflight.client import WarplineMcpClient
- monkeypatch.setenv("WARPLINE_API_URL", "http://localhost:9100")
+ monkeypatch.setenv("WARPLINE_MCP_CMD", "echo")
monkeypatch.setenv("LEGIS_SOURCE_ROOT", str(tmp_path))
monkeypatch.delenv("LEGIS_HMAC_KEY", raising=False) # engine-only: no protected gate
# NOTE: build_runtime(agent_id) takes ONLY agent_id (mcp.py:200); source root
# and DBs are env-driven (LEGIS_SOURCE_ROOT, mcp.py:275). There is NO
# source_root= kwarg — passing one raises TypeError before any assertion.
runtime = build_runtime("agent-x")
- assert isinstance(runtime.warpline, HttpWarplineClient)
+ assert isinstance(runtime.warpline, WarplineMcpClient)
def test_build_runtime_leaves_warpline_unwired_without_env(monkeypatch, tmp_path):
from legis.mcp import build_runtime
- monkeypatch.delenv("WARPLINE_API_URL", raising=False)
+ monkeypatch.delenv("WARPLINE_MCP_CMD", raising=False)
monkeypatch.setenv("LEGIS_SOURCE_ROOT", str(tmp_path))
runtime = build_runtime("agent-x")
assert runtime.warpline is None
-def test_build_runtime_degrades_warpline_to_none_on_bad_url(monkeypatch, tmp_path):
- # A misconfigured ADVISORY url must NOT crash the sole governance authority
+def test_build_runtime_degrades_warpline_to_none_on_bad_cmd(monkeypatch, tmp_path):
+ # A misconfigured ADVISORY command must NOT crash the sole governance authority
# at startup; it degrades to no advisory context (governance unaffected).
from legis.mcp import build_runtime
- monkeypatch.setenv("WARPLINE_API_URL", "not-a-valid-url")
+ monkeypatch.setenv("WARPLINE_MCP_CMD", " ") # blank after shlex.split -> fail-safe None
monkeypatch.setenv("LEGIS_SOURCE_ROOT", str(tmp_path))
monkeypatch.delenv("LEGIS_HMAC_KEY", raising=False)
runtime = build_runtime("agent-x")
@@ -3433,12 +3436,25 @@ def test_warpline_preflight_get_unavailable_when_unwired(tmp_path):
def test_warpline_preflight_get_checked_with_injected_client(tmp_path):
from legis.mcp import call_tool
+ _impact = {
+ "schema": "warpline.impact_radius.v1",
+ "ok": True,
+ "data": {"completeness": "FULL", "affected": [{"sei": "S1", "depth": 1}]},
+ "meta": {"local_only": True, "peer_side_effects": []},
+ }
+ _reverify = {
+ "schema": "warpline.reverify_worklist.v1",
+ "ok": True,
+ "data": {"completeness": "FULL", "items": []},
+ "meta": {"local_only": True, "peer_side_effects": []},
+ }
+
class _FakeWarpline:
def impact_radius(self, base, head):
- return {"affected": [{"sei": "S1"}], "count": 1}
+ return _impact
def reverify_worklist(self, base, head):
- return {"entries": [], "count": 0}
+ return _reverify
runtime, _store = _runtime(tmp_path)
runtime.warpline = _FakeWarpline()
@@ -3446,8 +3462,88 @@ def reverify_worklist(self, base, head):
assert not result.get("isError")
sc = result["structuredContent"]
assert sc["status"] == "checked"
- assert sc["impact_radius"] == {"affected": [{"sei": "S1"}], "count": 1}
- assert sc["reverify_worklist"] == {"entries": [], "count": 0}
+ assert sc["impact_radius"] == _impact
+ assert sc["reverify_worklist"] == _reverify
+
+
+# plainweave_preflight_get advisory sibling tool (mirrors warpline above)
+# ---------------------------------------------------------------------------
+
+
+def test_build_runtime_wires_plainweave_from_env(monkeypatch, tmp_path):
+ from legis.mcp import build_runtime
+ from legis.plainweave_preflight.client import PlainweaveMcpClient
+
+ monkeypatch.setenv("PLAINWEAVE_MCP_CMD", "echo")
+ monkeypatch.setenv("LEGIS_SOURCE_ROOT", str(tmp_path))
+ monkeypatch.delenv("LEGIS_HMAC_KEY", raising=False)
+ runtime = build_runtime("agent-x")
+ assert isinstance(runtime.plainweave, PlainweaveMcpClient)
+
+
+def test_build_runtime_leaves_plainweave_unwired_without_env(monkeypatch, tmp_path):
+ from legis.mcp import build_runtime
+
+ monkeypatch.delenv("PLAINWEAVE_MCP_CMD", raising=False)
+ monkeypatch.setenv("LEGIS_SOURCE_ROOT", str(tmp_path))
+ runtime = build_runtime("agent-x")
+ assert runtime.plainweave is None
+
+
+def test_build_runtime_degrades_plainweave_to_none_on_bad_cmd(monkeypatch, tmp_path):
+ # A misconfigured ADVISORY command must NOT crash the sole governance authority
+ # at startup; it degrades to no advisory context (governance unaffected).
+ from legis.mcp import build_runtime
+
+ monkeypatch.setenv("PLAINWEAVE_MCP_CMD", " ") # blank after shlex.split -> fail-safe None
+ monkeypatch.setenv("LEGIS_SOURCE_ROOT", str(tmp_path))
+ monkeypatch.delenv("LEGIS_HMAC_KEY", raising=False)
+ runtime = build_runtime("agent-x")
+ assert runtime.plainweave is None
+
+
+def test_plainweave_preflight_get_unavailable_when_unwired(tmp_path):
+ from legis.mcp import call_tool
+
+ runtime, _store = _runtime(tmp_path) # plainweave defaults to None
+ result = call_tool(runtime, "plainweave_preflight_get", {"base": "aaa"})
+ assert not result.get("isError")
+ assert result["structuredContent"] == {
+ "status": "unavailable",
+ "unavailable": [{"reason": "plainweave client not configured"}],
+ }
+
+
+def test_plainweave_preflight_get_checked_with_injected_client(tmp_path):
+ from legis.mcp import call_tool
+
+ _envelope = {
+ "schema": "weft.plainweave.preflight_facts.v1",
+ "ok": True,
+ "data": {
+ "freshness": "partial",
+ "facts": [],
+ "authority_boundary": {
+ "local_only": True,
+ "live_peer_calls": False,
+ "governance_verdicts": False,
+ },
+ },
+ "warnings": [],
+ "meta": {},
+ }
+
+ class _FakePlainweave:
+ def preflight_facts(self, base, head):
+ return _envelope
+
+ runtime, _store = _runtime(tmp_path)
+ runtime.plainweave = _FakePlainweave()
+ result = call_tool(runtime, "plainweave_preflight_get", {"base": "aaa", "head": "bbb"})
+ assert not result.get("isError")
+ sc = result["structuredContent"]
+ assert sc["status"] == "checked"
+ assert sc["preflight_facts"] == _envelope
# Task 5: attestation_get fail-closed scaffolding
diff --git a/tests/mcp/test_warpline_advisory_boundary.py b/tests/mcp/test_warpline_advisory_boundary.py
index 2864c63..de92cbe 100644
--- a/tests/mcp/test_warpline_advisory_boundary.py
+++ b/tests/mcp/test_warpline_advisory_boundary.py
@@ -17,6 +17,7 @@
from legis.enforcement.engine import EnforcementEngine
from legis.policy.grammar import AllowlistBoundary, PolicyGrammar
from legis.store.audit_store import AuditStore
+from legis.warpline_preflight.client import WarplineMcpClient
# ---------------------------------------------------------------------------
@@ -72,13 +73,38 @@ def _runtime(
class _HostileWarpline:
- """Returns arbitrary/garbage advisory data to prove it cannot perturb a verdict."""
+ """Returns envelopes with a GV-LG-3-VALID meta but hostile advisory payload.
+
+ The hostile values are in ``data.affected``/``data.items`` — the advisory
+ payload that must NOT perturb a governance verdict. The meta is deliberately
+ valid (``local_only:true, peer_side_effects:[]``) so the test proves that a
+ *hostile payload* (not a contract violation) is inert; a meta-violating
+ envelope would be refused → unavailable, making the byte-identity comparison
+ vacuously ``unavailable == unavailable``.
+ """
def impact_radius(self, base, head):
- return {"affected": [{"sei": "EVERYTHING"}], "count": 9999, "block": True}
+ return {
+ "schema": "warpline.impact_radius.v1",
+ "ok": True,
+ "data": {
+ "completeness": "FULL",
+ "affected": [{"sei": "EVERYTHING", "depth": 9999}],
+ "changed": [],
+ },
+ "meta": {"local_only": True, "peer_side_effects": [], "producer": {"tool": "hostile"}},
+ }
def reverify_worklist(self, base, head):
- return {"entries": [{"sei": "EVERYTHING", "reason": "force"}], "count": 9999}
+ return {
+ "schema": "warpline.reverify_worklist.v1",
+ "ok": True,
+ "data": {
+ "completeness": "FULL",
+ "items": [{"sei": "EVERYTHING", "reason": "force"}],
+ },
+ "meta": {"local_only": True, "peer_side_effects": [], "producer": {"tool": "hostile"}},
+ }
def _seed_real_verdict_runtime(tmp_path):
@@ -137,9 +163,47 @@ def test_governance_verdicts_byte_identical_warpline_unset_vs_hostile(tmp_path):
runtime_set.warpline = _HostileWarpline() # structurally present, hostile
setval = _run_governance_paths(runtime_set)
+ # GUARD: the hostile side must have actually reached status=="checked" — a
+ # _HostileWarpline that raises exceptions would produce "unavailable" on both
+ # sides and make the byte-identity assertion trivially pass while proving
+ # nothing. This side-assertion is NOT part of setval.
+ from legis.mcp import call_tool
+ pf = call_tool(runtime_set, "warpline_preflight_get", {"base": "aaa", "head": "bbb"})
+ assert pf["structuredContent"]["status"] == "checked", (
+ "_HostileWarpline returned unavailable — its envelope was rejected before "
+ "reaching the advisory layer; the byte-identity assertion is vacuous"
+ )
+
assert json.dumps(unset, sort_keys=True) == json.dumps(setval, sort_keys=True)
+def test_gv_lg_3_invalid_meta_peer_side_effects_yields_unavailable(tmp_path):
+ """Positive GV-LG-3 pin: an envelope with non-empty peer_side_effects is
+ refused by WarplineMcpClient._call (raises WarplineError), which propagates
+ through read_warpline_preflight as status='unavailable'. This ensures the
+ boundary check fires end-to-end, not just in unit tests of _call."""
+ from legis.mcp import call_tool
+
+ invalid_meta_envelope = {
+ "schema": "warpline.impact_radius.v1",
+ "ok": True,
+ "data": {"completeness": "FULL", "affected": []},
+ "meta": {
+ "local_only": True,
+ "peer_side_effects": ["some-peer"], # GV-LG-3 VIOLATION
+ "producer": {"tool": "warpline"},
+ },
+ }
+ runtime, _ = _runtime(tmp_path)
+ runtime.warpline = WarplineMcpClient(
+ invoke=lambda t, a: invalid_meta_envelope, repo="/r"
+ )
+ pf = call_tool(runtime, "warpline_preflight_get", {"base": "aaa", "head": "bbb"})
+ assert pf["structuredContent"]["status"] == "unavailable", (
+ "GV-LG-3: a non-empty peer_side_effects must yield unavailable, not checked"
+ )
+
+
def test_runtime_warpline_referenced_in_no_verdict_path_function():
# STRUCTURAL (defense-in-depth): runtime.warpline must appear in NO
# verdict-path / honesty-read source. NOTE inspect.getsource is a SHALLOW text
@@ -164,11 +228,20 @@ def test_runtime_warpline_referenced_in_no_verdict_path_function():
)
# --- explicit: non-handler verdict internals not in _TOOL_HANDLERS ---
+ from legis.service.governance import (
+ governance_read_unavailable,
+ read_governance_for_sei,
+ read_governance_for_sei_gate,
+ )
+
for fn in [
mcp._engine,
mcp._coached_engine,
mcp._governance_trail_records,
read_sei_attestations,
+ read_governance_for_sei,
+ read_governance_for_sei_gate,
+ governance_read_unavailable,
]:
src = inspect.getsource(fn)
assert ".warpline" not in src, f"{fn.__name__} references warpline"
diff --git a/tests/plainweave_preflight/__init__.py b/tests/plainweave_preflight/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/plainweave_preflight/fixtures/PROVENANCE.md b/tests/plainweave_preflight/fixtures/PROVENANCE.md
new file mode 100644
index 0000000..f6d0915
--- /dev/null
+++ b/tests/plainweave_preflight/fixtures/PROVENANCE.md
@@ -0,0 +1,62 @@
+# Plainweave preflight golden — provenance
+
+`plainweave-preflight-golden.json` freezes the one envelope shape Plainweave
+emits from its ONLY implemented producer:
+
+ * `plainweave_preflight_facts_get` → envelope schema `weft.plainweave.preflight_facts.v1`
+ (Plainweave ADR-006, `~/plainweave/docs/architecture/decisions/ADR-006-legis-preflight-fact-envelope.md`).
+
+The frozen envelope is the `commit_range` scope output over `HEAD~1..HEAD` with no
+explicit requirement / entity / baseline subjects — i.e. the **representative**
+output of the consumer's actual call (`read_plainweave_preflight` always invokes
+`scope_kind="commit_range"` with `base`/`head` and no subjects). That call resolves
+no facts locally (`facts: []`, `freshness: "partial"`) and carries the producer's
+standard capability-gap warnings (`live_diff_resolution_unavailable`,
+`linked_work_facts_unavailable`, `finding_facts_unavailable`). This mirrors the
+warpline golden precedent, which was likewise empty (`affected: []`, `NO_SNAPSHOT`)
+because the consumer passes the envelope through verbatim and never reads fact
+contents — a rich-fact fixture would misrepresent the call and test nothing extra.
+
+The producer's boundary assertions live in `data.authority_boundary`
+(`local_only: true, live_peer_calls: false, governance_verdicts: false`), NOT in
+`meta` (Plainweave's `meta` has no `local_only`/`peer_side_effects` — those are
+warpline's). Legis's `PlainweaveMcpClient._call` validates THOSE fields as the
+GV-LG-3 fail-closed gate, plus the mandatory `data.freshness` / `data.facts`.
+
+## This golden is CONSTRUCTED, not live-captured — live capture is a pending follow-up
+
+`_provenance.source` is `"constructed-from-frozen-producer-contract"`: the golden
+was built field-by-field from the frozen producer contract read in
+`~/plainweave/src/plainweave/mcp_surface.py`
+(`plainweave_preflight_facts_get`, `_preflight_scope`, `_preflight_warnings`,
+`_preflight_summary`, `_preflight_freshness`, the `authority_boundary` block),
+`~/plainweave/src/plainweave/envelopes.py` (`success_envelope`), and ADR-006 —
+NOT from a live `plainweave-mcp` session.
+
+This is deliberate and honest: this consumer was built from the **hub** session,
+whose MCP wiring is the hub's, not legis's plainweave wiring. A live MCP call from
+here would misroute and yield a false verdict, so it was not attempted. The golden
+does **not** claim `"live-captured"`; `test_golden_provenance_is_constructed_not_live`
+pins that honesty marker.
+
+**Required follow-up (flagged to the hub):** in a legis-rooted session, run a live
+`plainweave-mcp` capture of `plainweave_preflight_facts_get`
+(`scope_kind="commit_range"`, real `base`/`head`), confirm the bytes match this
+constructed golden field-for-field, and only then re-mark `_provenance.source` to
+`"live-captured"` and re-pin `GOLDEN_BLOB_SHA`.
+
+## Legis conforms to Plainweave's frozen producer — additive, enrich-only
+
+Legis is a CONSUMER of Plainweave's already-frozen `weft.plainweave.preflight_facts.v1`
+producer (ADR-006, registered + contract-tested on the Plainweave side). Legis's
+`PlainweaveMcpClient._call` validates the envelope and passes it through verbatim;
+it does not interpret, re-shape, or act on the facts. This creates **no Plainweave
+obligation** — it ships solo per the federation change discipline (additive,
+enrich-only). The read is a purely advisory SIBLING of the warpline advisory read
+and the governance honesty reads; it never perturbs a Legis governance verdict.
+
+## Re-pinning the golden
+
+After a deliberate re-construction (or a live recapture), re-pin `GOLDEN_BLOB_SHA`
+in `test_plainweave_preflight_oracle.py` to
+`git hash-object tests/plainweave_preflight/fixtures/plainweave-preflight-golden.json`.
diff --git a/tests/plainweave_preflight/fixtures/plainweave-preflight-golden.json b/tests/plainweave_preflight/fixtures/plainweave-preflight-golden.json
new file mode 100644
index 0000000..ed8b855
--- /dev/null
+++ b/tests/plainweave_preflight/fixtures/plainweave-preflight-golden.json
@@ -0,0 +1,91 @@
+{
+ "preflight_facts": {
+ "schema": "weft.plainweave.preflight_facts.v1",
+ "ok": true,
+ "data": {
+ "producer": {
+ "tool": "plainweave",
+ "version": "1.1.0",
+ "project": "legis"
+ },
+ "scope": {
+ "kind": "commit_range",
+ "base": "HEAD~1",
+ "head": "HEAD",
+ "requirement_ids": [],
+ "entity_refs": [],
+ "baseline_id": null
+ },
+ "generated_at": "2026-06-27T00:00:00+00:00",
+ "freshness": "partial",
+ "facts": [],
+ "summary": {
+ "info": 0,
+ "warn": 0,
+ "critical": 0,
+ "facts": 0,
+ "by_kind": {},
+ "by_freshness": {}
+ },
+ "warnings": [
+ {
+ "code": "live_diff_resolution_unavailable",
+ "severity": "info",
+ "message": "Plainweave did not call Legis or Loomweave to resolve the live diff.",
+ "freshness": "unavailable",
+ "provenance": {
+ "producer": "plainweave",
+ "inputs": []
+ }
+ },
+ {
+ "code": "linked_work_facts_unavailable",
+ "severity": "info",
+ "message": "Filigree linked-work facts are not joined by this local-only producer.",
+ "freshness": "unavailable",
+ "provenance": {
+ "producer": "plainweave",
+ "inputs": []
+ }
+ },
+ {
+ "code": "finding_facts_unavailable",
+ "severity": "info",
+ "message": "Wardline finding facts are not joined by this local-only producer.",
+ "freshness": "unavailable",
+ "provenance": {
+ "producer": "plainweave",
+ "inputs": []
+ }
+ }
+ ],
+ "provenance": {
+ "producer": "plainweave",
+ "inputs": []
+ },
+ "authority_boundary": {
+ "local_only": true,
+ "live_peer_calls": false,
+ "governance_verdicts": false,
+ "legis_policy_cells": "external"
+ }
+ },
+ "warnings": [],
+ "meta": {
+ "producer": {
+ "tool": "plainweave",
+ "version": "1.1.0"
+ },
+ "generated_at": "2026-06-27T00:00:00+00:00",
+ "project": "legis"
+ }
+ },
+ "_provenance": {
+ "source": "constructed-from-frozen-producer-contract",
+ "constructed": "2026-06-27",
+ "plainweave_version": "1.1.0",
+ "producer_contract": "weft.plainweave.preflight_facts.v1 (ADR-006)",
+ "scope": "commit_range HEAD~1..HEAD with no requirement/entity/baseline subjects -> empty facts (the representative output of the consumer's actual call)",
+ "live_capture_status": "PENDING: a live end-to-end capture against a real plainweave-mcp is a required follow-up in a legis-rooted session (the hub session's MCP wiring misroutes plainweave calls)."
+ }
+}
diff --git a/tests/plainweave_preflight/test_client.py b/tests/plainweave_preflight/test_client.py
new file mode 100644
index 0000000..760c98a
--- /dev/null
+++ b/tests/plainweave_preflight/test_client.py
@@ -0,0 +1,116 @@
+import pytest
+from legis.plainweave_preflight.client import (
+ PlainweaveClient,
+ PlainweaveError,
+ PlainweaveMcpClient,
+)
+
+_VALID_BOUNDARY = {
+ "local_only": True,
+ "live_peer_calls": False,
+ "governance_verdicts": False,
+ "legis_policy_cells": "external",
+}
+_KEEP = object() # sentinel: "use the valid default boundary" — DISTINCT from None (None IS a test case)
+
+
+def _env(*, boundary=_KEEP, facts=_KEEP, freshness="partial", schema="weft.plainweave.preflight_facts.v1", ok=True):
+ data = {
+ "producer": {"tool": "plainweave", "version": "1.1.0", "project": "legis"},
+ "scope": {"kind": "commit_range", "base": "aaa", "head": "bbb"},
+ "generated_at": "2026-06-27T00:00:00+00:00",
+ "summary": {"info": 0, "warn": 0, "critical": 0, "facts": 0},
+ "warnings": [],
+ "provenance": {"producer": "plainweave", "inputs": []},
+ "authority_boundary": dict(_VALID_BOUNDARY) if boundary is _KEEP else boundary,
+ }
+ if freshness is not None:
+ data["freshness"] = freshness
+ if facts is not _KEEP:
+ if facts is not None:
+ data["facts"] = facts
+ else:
+ data["facts"] = []
+ return {"schema": schema, "ok": ok, "data": data, "warnings": [], "meta": {}}
+
+
+def _recorder(responses):
+ calls = []
+
+ def invoke(tool, arguments):
+ calls.append((tool, arguments))
+ return responses.pop(0)
+
+ invoke.calls = calls
+ return invoke
+
+
+def test_protocol_is_runtime_checkable():
+ assert isinstance(PlainweaveMcpClient(invoke=_recorder([{}]), repo="/tmp/r"), PlainweaveClient)
+
+
+def test_preflight_calls_tool_with_commit_range_and_passes_envelope_through():
+ e = _env(facts=[])
+ inv = _recorder([e])
+ out = PlainweaveMcpClient(invoke=inv, repo="/tmp/r").preflight_facts("aaa", "bbb")
+ assert out == e
+ # NO 'repo' arg — the producer signature is scope_kind/base/head/...
+ assert inv.calls[0] == (
+ "plainweave_preflight_facts_get",
+ {"scope_kind": "commit_range", "base": "aaa", "head": "bbb"},
+ )
+
+
+@pytest.mark.parametrize("bad", [["not", "dict"], "str", 7, None])
+def test_non_dict_envelope_is_plainweave_error(bad):
+ with pytest.raises(PlainweaveError):
+ PlainweaveMcpClient(invoke=_recorder([bad]), repo="/tmp/r").preflight_facts("a", "b")
+
+
+def test_wrong_schema_or_not_ok_is_plainweave_error():
+ wrong = _env(schema="weft.plainweave.error.v1") # error envelope schema
+ with pytest.raises(PlainweaveError, match="schema"):
+ PlainweaveMcpClient(invoke=_recorder([wrong]), repo="/tmp/r").preflight_facts("a", "b")
+ notok = _env(ok=False)
+ with pytest.raises(PlainweaveError, match="ok"):
+ PlainweaveMcpClient(invoke=_recorder([notok]), repo="/tmp/r").preflight_facts("a", "b")
+
+
+def test_gv_lg_3_hostile_or_malformed_boundary_is_refused_fail_closed():
+ # A claimed live peer call (a side effect) is refused.
+ peer = _env(boundary={"local_only": True, "live_peer_calls": True, "governance_verdicts": False})
+ with pytest.raises(PlainweaveError, match="live_peer_calls"):
+ PlainweaveMcpClient(invoke=_recorder([peer]), repo="/tmp/r").preflight_facts("a", "b")
+ # A producer that claims to emit governance verdicts is refused — the whole
+ # point of ADR-006 is that plainweave emits facts only.
+ verdict = _env(boundary={"local_only": True, "live_peer_calls": False, "governance_verdicts": True})
+ with pytest.raises(PlainweaveError, match="governance_verdicts"):
+ PlainweaveMcpClient(invoke=_recorder([verdict]), repo="/tmp/r").preflight_facts("a", "b")
+ # local_only false / missing / non-dict boundaries all refuse.
+ for bad_boundary in (
+ {"local_only": False, "live_peer_calls": False, "governance_verdicts": False},
+ {"live_peer_calls": False, "governance_verdicts": False}, # missing local_only
+ "not-a-dict",
+ None,
+ 5,
+ ):
+ em = _env(boundary=bad_boundary)
+ with pytest.raises(PlainweaveError):
+ PlainweaveMcpClient(invoke=_recorder([em]), repo="/tmp/r").preflight_facts("a", "b")
+
+
+def test_degraded_envelope_missing_facts_or_freshness_is_plainweave_error():
+ # facts omitted -> degraded -> unavailable, not a bare empty 'checked'.
+ e = _env(facts=None)
+ with pytest.raises(PlainweaveError, match="freshness|facts"):
+ PlainweaveMcpClient(invoke=_recorder([e]), repo="/tmp/r").preflight_facts("a", "b")
+ # freshness omitted -> degraded.
+ e2 = _env(freshness=None)
+ with pytest.raises(PlainweaveError, match="freshness|facts"):
+ PlainweaveMcpClient(invoke=_recorder([e2]), repo="/tmp/r").preflight_facts("a", "b")
+
+
+def test_non_dict_data_is_plainweave_error():
+ bad = {"schema": "weft.plainweave.preflight_facts.v1", "ok": True, "data": "not-a-dict"}
+ with pytest.raises(PlainweaveError, match="data"):
+ PlainweaveMcpClient(invoke=_recorder([bad]), repo="/tmp/r").preflight_facts("a", "b")
diff --git a/tests/plainweave_preflight/test_plainweave_preflight_oracle.py b/tests/plainweave_preflight/test_plainweave_preflight_oracle.py
new file mode 100644
index 0000000..d442072
--- /dev/null
+++ b/tests/plainweave_preflight/test_plainweave_preflight_oracle.py
@@ -0,0 +1,237 @@
+"""Weft plainweave-preflight conformance oracle — Legis as consumer.
+
+Legis reads plainweave's ADVISORY preflight surface via
+``legis.plainweave_preflight.client.PlainweaveMcpClient`` and
+``legis.service.preflight.read_plainweave_preflight``. This oracle freezes the real
+envelope shape plainweave emits (``weft.plainweave.preflight_facts.v1``, ADR-006)
+and drives legis's REAL parse path over the frozen bytes, so a shape change fails
+CI until legis updates the consumer.
+
+Mirrors ``tests/warpline_preflight/test_warpline_preflight_oracle.py``:
+
+ * Layer-1 byte-pin (``test_golden_byte_pin``): UNMARKED, default-suite,
+ recomputes the git blob sha1 in-process and fails CLOSED on any byte drift.
+ * Non-circular consumer oracle (``test_golden_flows_through_the_real_parser``):
+ the frozen golden BYTES flow through legis's real ``PlainweaveMcpClient._call``
+ (schema/ok/authority_boundary/freshness/facts validation) via a fake invoke
+ replaying the golden; assertions are HARDCODED literals, NEVER a re-parse.
+ * Layer-2 source recheck (``test_golden_matches_plainweave_source``): compares
+ the frozen golden to plainweave's contract fixture; SKIPS CLEAN when absent.
+
+PROVENANCE: unlike the warpline golden, this golden is CONSTRUCTED from the frozen
+producer contract (ADR-006 + mcp_surface.py), NOT live-captured — the consumer was
+built from the hub session whose MCP wiring misroutes plainweave. A live capture is
+a flagged, required follow-up in a legis-rooted session. See ``fixtures/PROVENANCE.md``.
+"""
+from __future__ import annotations
+
+import hashlib
+import json
+import os
+from pathlib import Path
+
+import pytest
+
+from legis.plainweave_preflight.client import PlainweaveMcpClient
+from legis.service.preflight import read_plainweave_preflight
+
+FIX = Path(__file__).parent / "fixtures"
+GOLDEN_PATH = FIX / "plainweave-preflight-golden.json"
+
+# git blob sha1 of tests/plainweave_preflight/fixtures/plainweave-preflight-golden.json.
+# Frozen to the constructed weft.plainweave.preflight_facts.v1 envelope (ADR-006).
+# Update ONLY after a deliberate re-construction or a live re-capture from a real
+# plainweave-mcp run; re-pinning is the trigger for a consumer-contract review.
+# See fixtures/PROVENANCE.md.
+GOLDEN_BLOB_SHA = "ed8b8559cafc9bc954b9748777bdd31711acd369"
+
+PREFLIGHT_SCHEMA = "weft.plainweave.preflight_facts.v1"
+
+
+# ---------------------------------------------------------------------------
+# Layer-1: fail-closed byte-pin (UNMARKED — runs in the default suite).
+# ---------------------------------------------------------------------------
+def _git_blob_sha1(data: bytes) -> str:
+ return hashlib.sha1(b"blob %d\0" % len(data) + data).hexdigest()
+
+
+def test_golden_byte_pin():
+ data = GOLDEN_PATH.read_bytes()
+ assert _git_blob_sha1(data) == GOLDEN_BLOB_SHA, (
+ "plainweave-preflight golden has drifted from its pinned bytes; update "
+ "GOLDEN_BLOB_SHA only after a deliberate re-construction / re-capture "
+ "(re-check PROVENANCE.md and re-run `git hash-object` on the new golden)."
+ )
+
+
+# ---------------------------------------------------------------------------
+# Provenance honesty guard: this golden is CONSTRUCTED, not live-captured, and
+# must say so — it must NOT falsely claim a live capture it never had. The live
+# capture is the flagged follow-up, not a thing we fake (the inverse of the
+# warpline oracle, which can force a real capture because warpline routes here).
+# ---------------------------------------------------------------------------
+def test_golden_provenance_is_constructed_not_live():
+ golden = json.loads(GOLDEN_PATH.read_bytes())
+ source = golden.get("_provenance", {}).get("source")
+ assert source == "constructed-from-frozen-producer-contract", (
+ "this golden was built from the frozen ADR-006 producer contract, not a "
+ "live plainweave-mcp capture; its provenance must say so honestly."
+ )
+ assert source != "live-captured", "must not falsely claim a live capture"
+ # The follow-up must stay visible until a real legis-rooted capture lands.
+ assert "PENDING" in golden["_provenance"]["live_capture_status"]
+
+
+# ---------------------------------------------------------------------------
+# Non-circular consumer oracle: golden BYTES -> PlainweaveMcpClient._call
+# (schema/ok/authority_boundary/freshness/facts validated). Assertions are
+# HARDCODED literals from the frozen golden — NEVER a re-parse of the golden.
+# ---------------------------------------------------------------------------
+def _dispatch_invoke(golden: dict):
+ """Return an invoke that replays the frozen preflight_facts envelope."""
+ def invoke(tool: str, args: dict):
+ if tool == "plainweave_preflight_facts_get":
+ return golden["preflight_facts"]
+ raise AssertionError(f"unexpected tool in oracle: {tool!r}")
+ return invoke
+
+
+def test_consumer_sends_commit_range_scope_with_no_repo_arg():
+ """The producer tool takes NO 'repo' arg — its signature is scope_kind/base/
+ head/... A blind warpline copy that sent {'repo':..,'rev_range':..} would be
+ rejected by the producer. Pin the exact arguments the consumer sends."""
+ golden = json.loads(GOLDEN_PATH.read_bytes())
+ seen: list[tuple] = []
+
+ def invoke(tool, args):
+ seen.append((tool, args))
+ return golden["preflight_facts"]
+
+ PlainweaveMcpClient(invoke=invoke, repo="/r").preflight_facts("base-sha", "head-sha")
+ assert seen[0] == (
+ "plainweave_preflight_facts_get",
+ {"scope_kind": "commit_range", "base": "base-sha", "head": "head-sha"},
+ )
+ assert "repo" not in seen[0][1]
+ assert "rev_range" not in seen[0][1]
+
+
+def test_golden_flows_through_the_real_parser_with_hardcoded_assertions():
+ """Drive the FROZEN golden bytes through PlainweaveMcpClient._call (the real
+ schema/ok/authority_boundary/freshness/facts validation), via a fake invoke.
+ Assert HARDCODED values from the golden — NEVER a re-parse of the golden."""
+ golden = json.loads(GOLDEN_PATH.read_bytes())
+
+ env = PlainweaveMcpClient(
+ invoke=lambda t, a: golden["preflight_facts"], repo="/r"
+ ).preflight_facts("b", "h")
+
+ # Hardcoded schema — the real parse gate; any envelope rename breaks here.
+ assert env["schema"] == "weft.plainweave.preflight_facts.v1"
+ assert env["ok"] is True
+ # Hardcoded authority_boundary fields validated by _call (GV-LG-3 boundary).
+ boundary = env["data"]["authority_boundary"]
+ assert boundary["local_only"] is True
+ assert boundary["live_peer_calls"] is False
+ assert boundary["governance_verdicts"] is False
+ # Hardcoded data shape — empty facts and the partial freshness from commit_range.
+ assert env["data"]["freshness"] == "partial"
+ assert env["data"]["facts"] == []
+ assert env["data"]["producer"]["tool"] == "plainweave"
+ assert env["data"]["scope"]["kind"] == "commit_range"
+
+
+def test_read_plainweave_preflight_over_golden_is_checked():
+ """The full service read: discriminated 'checked' with the facts envelope,
+ parsed through legis's real PlainweaveMcpClient._call over the frozen golden.
+ Assertions are hardcoded — not a re-parse of the golden."""
+ golden = json.loads(GOLDEN_PATH.read_bytes())
+ client = PlainweaveMcpClient(invoke=_dispatch_invoke(golden), repo="/r")
+ result = read_plainweave_preflight(client, "base-sha", "head-sha")
+
+ # Hardcoded status discriminant.
+ assert result["status"] == "checked"
+ # Hardcoded sub-response schema and boundary — the validation was real.
+ assert result["preflight_facts"]["schema"] == "weft.plainweave.preflight_facts.v1"
+ assert result["preflight_facts"]["data"]["authority_boundary"]["local_only"] is True
+ assert result["preflight_facts"]["data"]["authority_boundary"]["governance_verdicts"] is False
+ assert result["preflight_facts"]["data"]["freshness"] == "partial"
+ assert result["preflight_facts"]["data"]["facts"] == []
+
+
+# ---------------------------------------------------------------------------
+# Layer-2: cross-repo CONSUMER conformance vs plainweave's OWN producer golden
+# (skip-clean when no sibling plainweave checkout).
+#
+# Plainweave vendors its own byte-pinned, live-producer-rechecked golden for the
+# weft.plainweave.preflight_facts.v1 envelope at
+# ``tests/fixtures/contracts/legis/preflight-facts.json``. That golden is the
+# producer's ``{"schema": ..., **data}`` payload (NOT the full MCP envelope: no
+# ``ok``/``data``/``meta`` wrapper) and exercises the RICH pending_diff scenario
+# — all nine fact kinds. This check wraps THAT payload into the full MCP envelope
+# and drives it through legis's REAL ``PlainweaveMcpClient._call`` +
+# ``read_plainweave_preflight``, asserting the consumer parses plainweave's own
+# contract-tested output. If plainweave's producer shape drifts (a fact kind,
+# section, or the authority_boundary triad changes) in a way that breaks the
+# consumer parse, this reds when a sibling plainweave checkout is present.
+# ---------------------------------------------------------------------------
+def _plainweave_source_fixture() -> Path | None:
+ relative = Path("tests") / "fixtures" / "contracts" / "legis" / "preflight-facts.json"
+ candidates: list[Path] = []
+ if env := os.environ.get("PLAINWEAVE_REPO"):
+ candidates.append(Path(env) / relative)
+ candidates.append(Path(__file__).resolve().parents[3] / "plainweave" / relative)
+ return next((path for path in candidates if path.exists()), None)
+
+
+def _envelope_from_producer_payload(payload: dict) -> dict:
+ """Reconstruct the full MCP success-envelope from plainweave's vendored
+ ``{"schema": ..., **data}`` producer payload (the shape its wire golden pins).
+ Mirrors ``plainweave.envelopes.success_envelope`` so the consumer sees exactly
+ what the MCP tool would return over the wire."""
+ data = {key: value for key, value in payload.items() if key != "schema"}
+ return {
+ "schema": payload["schema"],
+ "ok": True,
+ "data": data,
+ "warnings": [],
+ "meta": {
+ "producer": {"tool": "plainweave", "version": data["producer"]["version"]},
+ "generated_at": data["generated_at"],
+ "project": data["producer"].get("project"),
+ },
+ }
+
+
+def test_consumer_parses_plainweave_own_producer_golden():
+ source = _plainweave_source_fixture()
+ if source is None:
+ pytest.skip(
+ "no sibling plainweave checkout — plainweave's producer golden lives at "
+ "/tests/fixtures/contracts/legis/preflight-facts.json (its own "
+ "byte-pinned, live-producer-rechecked fixture). Set PLAINWEAVE_REPO or place "
+ "a sibling plainweave checkout to enable this cross-repo consumer-conformance "
+ "check. See fixtures/PROVENANCE.md."
+ )
+ payload = json.loads(source.read_text(encoding="utf-8"))
+ # Sanity: this IS the preflight-facts producer payload (schema+data shape).
+ assert payload["schema"] == PREFLIGHT_SCHEMA
+ assert "ok" not in payload and "data" not in payload # flattened, not the full envelope
+
+ envelope = _envelope_from_producer_payload(payload)
+ client = PlainweaveMcpClient(invoke=lambda t, a: envelope, repo="/r")
+ result = read_plainweave_preflight(client, "main", "WORKTREE")
+
+ # The consumer's REAL parser ACCEPTS plainweave's own contract-tested output:
+ # status checked, boundary validated, the rich 9-fact payload passed through.
+ assert result["status"] == "checked", (
+ "legis's consumer parser rejected plainweave's own producer golden — the "
+ "weft.plainweave.preflight_facts.v1 producer shape drifted from what the "
+ "consumer validates (schema/ok/authority_boundary/freshness/facts)."
+ )
+ parsed = result["preflight_facts"]["data"]
+ assert parsed["authority_boundary"]["local_only"] is True
+ assert parsed["authority_boundary"]["governance_verdicts"] is False
+ assert len(parsed["facts"]) == 9 # the rich pending_diff scenario, verbatim
+ kinds = {fact["kind"] for fact in parsed["facts"]}
+ assert "untraced_change" in kinds and "baseline_drift" in kinds
diff --git a/tests/plainweave_preflight/test_stdio_invoke.py b/tests/plainweave_preflight/test_stdio_invoke.py
new file mode 100644
index 0000000..a2a0d33
--- /dev/null
+++ b/tests/plainweave_preflight/test_stdio_invoke.py
@@ -0,0 +1,132 @@
+"""Tests for StdioMcpInvoke — the production stdio JSON-RPC transport.
+
+Fault paths: every transport/parse fault must raise PlainweaveError (fail closed).
+The total exception conversion here is what lets read_plainweave_preflight catch
+ONLY PlainweaveError and still guarantee "never escapes as INTERNAL_ERROR".
+
+NOTE: unlike the warpline transport test, there is no real captured plainweave-mcp
+session fixture — a live capture is a flagged follow-up in a legis-rooted session
+(the hub session's MCP wiring misroutes plainweave). These tests exercise the real
+message order + result shape against a fake server, which is sufficient for the
+transport contract; the live capture validates the end-to-end seam.
+"""
+import sys
+import textwrap
+
+import pytest
+
+from legis.plainweave_preflight.client import StdioMcpInvoke, PlainweaveError
+
+_PREFLIGHT_ENV = (
+ '{"schema":"weft.plainweave.preflight_facts.v1","ok":true,'
+ '"data":{"freshness":"partial","facts":[],'
+ '"authority_boundary":{"local_only":true,"live_peer_calls":false,"governance_verdicts":false}},'
+ '"warnings":[],"meta":{}}'
+)
+
+
+def _script(tmp_path, body):
+ p = tmp_path / "fake.py"
+ p.write_text(textwrap.dedent(body))
+ return [sys.executable, str(p)]
+
+
+_OK = f'''
+ import sys, json
+ for line in sys.stdin:
+ m = json.loads(line); mid = m.get("id")
+ if m.get("method") == "initialize":
+ print(json.dumps({{"jsonrpc":"2.0","id":mid,"result":{{"protocolVersion":"2025-06-18","capabilities":{{}},"serverInfo":{{"name":"f","version":"0"}}}}}}), flush=True)
+ elif m.get("method") == "tools/call":
+ env = json.loads({_PREFLIGHT_ENV!r})
+ print(json.dumps({{"jsonrpc":"2.0","id":mid,"result":{{"content":[{{"type":"text","text":"{{}}"}}],"structuredContent":env,"isError":False}}}}), flush=True)
+'''
+
+
+def test_round_trips_against_fake_server(tmp_path):
+ env = StdioMcpInvoke(command=_script(tmp_path, _OK))(
+ "plainweave_preflight_facts_get", {"scope_kind": "commit_range", "base": "a", "head": "b"}
+ )
+ assert env["schema"] == "weft.plainweave.preflight_facts.v1"
+ assert env["data"]["facts"] == []
+ assert env["data"]["authority_boundary"]["local_only"] is True
+
+
+def test_content_text_fallback_parse(tmp_path):
+ """When structuredContent is absent, StdioMcpInvoke must parse the envelope
+ from content[0].text."""
+ env_file = tmp_path / "envelope.json"
+ env_file.write_text(_PREFLIGHT_ENV, encoding="utf-8")
+ body = f'''
+ import sys, json
+ env_text = open({str(env_file)!r}, encoding="utf-8").read()
+ for line in sys.stdin:
+ m = json.loads(line); mid = m.get("id")
+ if m.get("method") == "initialize":
+ print(json.dumps({{"jsonrpc":"2.0","id":mid,"result":{{"protocolVersion":"2025-06-18","capabilities":{{}},"serverInfo":{{"name":"f","version":"0"}}}}}}), flush=True)
+ elif m.get("method") == "tools/call":
+ print(json.dumps({{"jsonrpc":"2.0","id":mid,"result":{{"content":[{{"type":"text","text":env_text}}],"isError":False}}}}), flush=True)
+ '''
+ env = StdioMcpInvoke(command=_script(tmp_path, body))(
+ "plainweave_preflight_facts_get", {"scope_kind": "commit_range", "base": "a", "head": "b"}
+ )
+ assert env["schema"] == "weft.plainweave.preflight_facts.v1"
+ assert env["data"]["freshness"] == "partial"
+
+
+@pytest.mark.parametrize("body,match", [
+ ('import sys\n', "no JSON-RPC response"), # empty stdout
+ ('print("not json", flush=True)\n', "non-JSON line"), # non-JSON line
+ ('import json;print(json.dumps({"jsonrpc":"2.0","id":2,"result":7}))\n', "result"), # scalar result
+ ('import json;print(json.dumps({"jsonrpc":"2.0","id":2,"result":{"isError":True,"content":[]}}))\n', "error result"), # isError
+ ('import json;print(json.dumps({"jsonrpc":"2.0","id":2,"error":{"code":-1,"message":"boom"}}))\n', "boom"), # jsonrpc error
+])
+def test_fault_paths_all_raise_plainweave_error(tmp_path, body, match):
+ with pytest.raises(PlainweaveError, match=match):
+ StdioMcpInvoke(command=_script(tmp_path, body))(
+ "plainweave_preflight_facts_get", {"scope_kind": "commit_range", "base": "a", "head": "b"}
+ )
+
+
+def test_no_usable_envelope_is_plainweave_error(tmp_path):
+ # result has neither structuredContent nor a content[].text block.
+ body = (
+ 'import json,sys\n'
+ 'for line in sys.stdin:\n'
+ ' m=json.loads(line); mid=m.get("id")\n'
+ ' if m.get("method")=="tools/call":\n'
+ ' print(json.dumps({"jsonrpc":"2.0","id":mid,"result":{"content":[],"isError":False}}), flush=True)\n'
+ )
+ with pytest.raises(PlainweaveError, match="no usable envelope"):
+ StdioMcpInvoke(command=_script(tmp_path, body))(
+ "plainweave_preflight_facts_get", {"scope_kind": "commit_range", "base": "a", "head": "b"}
+ )
+
+
+def test_missing_executable_is_plainweave_error(tmp_path):
+ with pytest.raises(PlainweaveError):
+ StdioMcpInvoke(command=[str(tmp_path / "nope")])(
+ "plainweave_preflight_facts_get", {"scope_kind": "commit_range", "base": "a", "head": "b"}
+ )
+
+
+def test_empty_command_is_plainweave_error():
+ with pytest.raises(PlainweaveError, match="empty"):
+ StdioMcpInvoke(command=[])(
+ "plainweave_preflight_facts_get", {"scope_kind": "commit_range", "base": "a", "head": "b"}
+ )
+
+
+def test_timeout_is_plainweave_error(tmp_path):
+ with pytest.raises(PlainweaveError):
+ StdioMcpInvoke(command=_script(tmp_path, "import time;time.sleep(5)\n"), timeout=0.3)(
+ "plainweave_preflight_facts_get", {"scope_kind": "commit_range", "base": "a", "head": "b"}
+ )
+
+
+def test_oversize_stdout_is_plainweave_error(tmp_path):
+ body = 'import sys;sys.stdout.buffer.write(b"x"*2_000_000)\n'
+ with pytest.raises(PlainweaveError, match="too large"):
+ StdioMcpInvoke(command=_script(tmp_path, body))(
+ "plainweave_preflight_facts_get", {"scope_kind": "commit_range", "base": "a", "head": "b"}
+ )
diff --git a/tests/posture/test_ledger.py b/tests/posture/test_ledger.py
index c29d82c..cd8721a 100644
--- a/tests/posture/test_ledger.py
+++ b/tests/posture/test_ledger.py
@@ -125,14 +125,16 @@ def test_read_floor_ignores_metadata_floor_field(tmp_path):
assert ledger.read_floor() == "protected"
-def test_read_floor_uses_tail_read(tmp_path, monkeypatch):
+def test_read_floor_verifies_integrity_before_returning_floor(tmp_path):
+ """read_floor() gates on verify_integrity() before the tail scan: on a valid
+ chain the gate passes and the floor is returned. Supersedes the old
+ 'read_floor must not call read_all' guard — the integrity-before-trust gate
+ DELIBERATELY calls read_all via verify_integrity; that property is RETIRED,
+ not regressed. (legis-476ab6f125.)
+ """
ledger = PostureLedger(_url(tmp_path), initialize=True)
ledger.genesis(key_fingerprint="ab" * 32, agent_id="installer", recorded_at="t0")
-
- def _boom():
- raise AssertionError("read_floor must not call read_all (hot path)")
-
- monkeypatch.setattr(ledger.store, "read_all", _boom)
+ assert ledger.store.verify_integrity() is True
assert ledger.read_floor() == "chill"
diff --git a/tests/posture/test_security_honesty.py b/tests/posture/test_security_honesty.py
index ae844e6..a374a6c 100644
--- a/tests/posture/test_security_honesty.py
+++ b/tests/posture/test_security_honesty.py
@@ -19,7 +19,9 @@
from __future__ import annotations
import hashlib
+import json
import logging
+import sqlite3
import pytest
@@ -29,6 +31,7 @@
from legis.posture.ledger import (
REFUSED_NO_SESSION,
PostureLedger,
+ _sqlite_file,
set_floor,
)
from legis.posture.records import KIND_KEY_RESET, KIND_TRANSITION
@@ -390,3 +393,120 @@ def test_operator_key_never_in_logs(caplog):
assert key_hex not in caplog.text, backend_id
finally:
del os.environ[_OPERATOR_KEY_ENV]
+
+
+# -- test_read_floor_fails_closed_on_integrity_break --------------------------
+
+
+def test_read_floor_fails_closed_on_integrity_break(tmp_path):
+ """A raw-DB tail record that lowers the floor but breaks the keyless hash
+ chain must NOT be trusted: read_floor() fails closed (returns None ->
+ structured), never the forged floor. (legis-476ab6f125; PRD-0005 crit 1.)
+
+ Uses the canonical raw-file-write attacker model (sqlite3.connect, as in
+ tests/store/test_audit_store.py:22,78), not the ORM. In-place-edit / reorder
+ / seq-gap tamper is already pinned for the same gate at
+ tests/store/test_audit_store.py:78 and :246; this test exercises the
+ tail-append vector through read_floor specifically.
+ """
+ key_hex = mint_key()
+ key_bytes = bytes.fromhex(key_hex)
+ ledger, _ = _genesis(tmp_path, key_hex=key_hex) # GENESIS @ chill
+
+ # Elevate to protected via a signed transition so a downgrade is visible.
+ _open_recorded_session(ledger, signer=_MemSigner(key_bytes))
+ set_floor(
+ "protected", ledger=ledger, signer=_MemSigner(key_bytes),
+ agent_id="op", rationale="tighten", clock=FixedClock("t1"),
+ )
+ assert ledger.read_floor() == "protected"
+
+ # Simulate a raw-file-write attacker: append a tail row claiming
+ # floor="chill" with a BROKEN chain (garbage hashes). INSERT is allowed:
+ # the append-only triggers block only UPDATE/DELETE.
+ head_seq, _ = ledger.store.get_latest_sequence_and_hash()
+ forged = {
+ "kind": KIND_TRANSITION, "floor": "chill", "operator_sig": None,
+ "key_fingerprint": "x", "agent_id": "attacker", "recorded_at": "t9",
+ "rationale": "forged", "session_id": None,
+ }
+ conn = sqlite3.connect(str(_sqlite_file(ledger._url)))
+ try:
+ conn.execute(
+ "INSERT INTO audit_log (seq, payload, content_hash, prev_hash, "
+ "chain_hash) VALUES (:seq, :payload, :ch, :ph, :xh)",
+ {
+ "seq": head_seq + 1, "payload": json.dumps(forged),
+ "ch": "0" * 64, # does NOT match payload -> verify_integrity fails
+ "ph": "0" * 64, "xh": "0" * 64,
+ },
+ )
+ conn.commit()
+ finally:
+ conn.close()
+
+ # Pre-fix: the descending scan returns the forged "chill". Post-fix: the
+ # broken chain fails verify_integrity, so read_floor fails closed.
+ assert ledger.read_floor() is None
+
+
+# -- test_read_floor_recomputed_chain_forgery_is_conceded_residual ------------
+
+
+def test_read_floor_recomputed_chain_forgery_is_conceded_residual(tmp_path):
+ """DOCUMENTS the limit so it is visible in the suite, not implied-closed.
+
+ A file-write attacker who *recomputes* the KEYLESS chain (valid content_hash,
+ prev_hash, chain_hash) on a forged floor-lowering TRANSITION passes
+ verify_integrity() — the keyless hot read CANNOT detect this. On a
+ non-rekeyed ledger it is caught by NEITHER this hot read NOR `doctor`:
+ doctor's operator_sig verification (_transition_acknowledges) runs ONLY on
+ the KEY_RESET-acknowledgment path, and there is no KEY_RESET here. It is the
+ conceded raw-file-write residual (README "Known security limitations",
+ README.md:137). A general per-transition operator_sig audit in doctor would
+ close it operator-side (separate follow-up). If a future change makes
+ read_floor reject this, that is a STRENGTHENING: update this test
+ deliberately — do not let it silently flip for the wrong reason.
+ """
+ from legis.canonical import canonical_json, content_hash
+ # Recompute the chain with the PRODUCTION primitive so the residual is not
+ # undermined by a divergent re-implementation (precedent: test_audit_store.py:10).
+ from legis.store.audit_store import _chain
+
+ key_hex = mint_key()
+ key_bytes = bytes.fromhex(key_hex)
+ ledger, _ = _genesis(tmp_path, key_hex=key_hex)
+ _open_recorded_session(ledger, signer=_MemSigner(key_bytes))
+ set_floor(
+ "protected", ledger=ledger, signer=_MemSigner(key_bytes),
+ agent_id="op", rationale="tighten", clock=FixedClock("t1"),
+ )
+ assert ledger.read_floor() == "protected"
+
+ # Forge a floor-lowering TRANSITION with a CORRECTLY recomputed keyless chain.
+ head_seq, head_chain = ledger.store.get_latest_sequence_and_hash()
+ forged = {
+ "kind": KIND_TRANSITION, "floor": "chill", "operator_sig": "junk",
+ "key_fingerprint": "x", "agent_id": "attacker", "recorded_at": "t9",
+ "rationale": "forged", "session_id": None,
+ }
+ c_hash = content_hash(forged) # correct keyless content hash
+ chain_hash = _chain(head_chain, c_hash) # correct keyless chain link
+ conn = sqlite3.connect(str(_sqlite_file(ledger._url)))
+ try:
+ conn.execute(
+ "INSERT INTO audit_log (seq, payload, content_hash, prev_hash, "
+ "chain_hash) VALUES (:seq, :payload, :ch, :ph, :xh)",
+ {
+ "seq": head_seq + 1, "payload": canonical_json(forged),
+ "ch": c_hash, "ph": head_chain, "xh": chain_hash,
+ },
+ )
+ conn.commit()
+ finally:
+ conn.close()
+
+ # Integrity holds (keyless chain valid) -> the keyless read is fooled.
+ # This asserts the DOCUMENTED RESIDUAL, not a desired guarantee.
+ assert ledger.store.verify_integrity() is True
+ assert ledger.read_floor() == "chill"
diff --git a/tests/service/test_governance_read.py b/tests/service/test_governance_read.py
new file mode 100644
index 0000000..5eada81
--- /dev/null
+++ b/tests/service/test_governance_read.py
@@ -0,0 +1,284 @@
+"""Task 2 tests: read_governance_for_sei, read_governance_for_sei_gate,
+governance_read_unavailable.
+
+TDD red→green. All imports of the new names happen inside the test bodies or
+at module level — either way the ImportError on a missing symbol is "the right
+failure reason" for Step 2 of the TDD cycle.
+"""
+
+import pytest
+
+from legis.clock import FixedClock
+from legis.enforcement.protected import ProtectedGate
+from legis.enforcement.verdict import JudgeOpinion, Verdict
+from legis.identity.entity_key import EntityKey
+from legis.service.errors import AuditIntegrityError, ProtectedKeyRequiredError
+from legis.service.governance import (
+ governance_read_unavailable,
+ read_governance_for_sei,
+ read_governance_for_sei_gate,
+)
+from legis.store.audit_store import AuditStore
+
+# ── shared fixtures ──────────────────────────────────────────────────────────
+
+_SEI = "loomweave:eid:test-sei"
+_CLOCK = FixedClock("2026-06-02T12:00:00+00:00")
+
+
+class _AcceptJudge:
+ def evaluate(self, record):
+ return JudgeOpinion(verdict=Verdict.ACCEPTED, model="judge@1", rationale="ok")
+
+
+def _stable_key(sei=_SEI):
+ return EntityKey(value=sei, identity_stable=True)
+
+
+def _operator_override_gate(tmp_path, *, sei=_SEI, content_hash="ch:override-content"):
+ """Build a genuine ProtectedGate, submit an operator_override, return (gate, store, result)."""
+ store = AuditStore(f"sqlite:///{tmp_path}/gov.db")
+ gate = ProtectedGate(store, _CLOCK, judge=_AcceptJudge(), key=b"protected-key")
+ result = gate.operator_override(
+ policy="protected.x",
+ entity_key=_stable_key(sei),
+ rationale="operator clears",
+ operator_id="operator-1",
+ file_fingerprint="sha256:ff",
+ ast_path="ap",
+ extensions={"loomweave": {"content_hash": content_hash}},
+ )
+ return gate, store, result
+
+
+# ── positive projection: operator_override ───────────────────────────────────
+
+
+def test_projects_override_to_clearance_record(tmp_path):
+ """A genuine signed operator_override maps to the 7-field clearance record.
+
+ posture must be "protected_override" (the provable mechanism).
+ Coupling note: read_sei_attestations admits the record whose
+ judge_verdict == Verdict.OVERRIDDEN_BY_OPERATOR.value; the posture map
+ key is "operator_override" (the kind string, not the enum constant).
+ """
+ _, store, _ = _operator_override_gate(tmp_path, content_hash="ch:override-content")
+ out = read_governance_for_sei(store.read_all(), _SEI)
+ assert out["status"] == "checked"
+ assert out["sei"] == _SEI
+ assert len(out["records"]) == 1
+ rec = out["records"][0]
+ assert rec == {
+ "sei": _SEI,
+ "disposition": "cleared",
+ "posture": "protected_override",
+ "authority": "operator",
+ "as_of": "2026-06-02T12:00:00+00:00",
+ "reasons": ["operator_override"],
+ "content_hash": "ch:override-content",
+ }
+
+
+# ── positive projection: signoff_cleared (monkeypatched) ────────────────────
+
+
+def test_signoff_projection_full_dict(monkeypatch):
+ """Monkeypatch read_sei_attestations to a signoff_cleared attestation; assert
+ all 7 clearance record fields (disposition/posture/authority/as_of/reasons/content_hash).
+ Patching legis.service.governance.read_sei_attestations — the call-site namespace.
+ """
+ fake_attestations = {
+ "status": "checked",
+ "sei": _SEI,
+ "attestations": [
+ {
+ "kind": "signoff_cleared",
+ "content_hash": "ch:signoff-content",
+ "recorded_at": "2026-06-02T12:00:00+00:00",
+ "seq": 2,
+ "signoff_seq": 1,
+ }
+ ],
+ }
+ monkeypatch.setattr(
+ "legis.service.governance.read_sei_attestations",
+ lambda records, sei: fake_attestations,
+ )
+ out = read_governance_for_sei([], _SEI)
+ assert out["status"] == "checked"
+ assert len(out["records"]) == 1
+ rec = out["records"][0]
+ assert rec == {
+ "sei": _SEI,
+ "disposition": "cleared",
+ "posture": "operator_signoff",
+ "authority": "operator",
+ "as_of": "2026-06-02T12:00:00+00:00",
+ "reasons": ["signoff_cleared"],
+ "content_hash": "ch:signoff-content",
+ }
+
+
+# ── honest absence: verified trail, no clearance ────────────────────────────
+
+
+def test_verified_but_no_clearance_is_checked_empty():
+ """An empty (or non-matching) verified trail → status:'checked', records:[] (NOT unavailable)."""
+ out = read_governance_for_sei([], _SEI)
+ assert out == {"status": "checked", "sei": _SEI, "records": []}
+
+
+# ── as_of guard: omit records with absent or non-RFC3339 timestamp ───────────
+
+
+def test_as_of_none_is_omitted(monkeypatch):
+ """An attestation with recorded_at: None must be OMITTED, not emitted with as_of:null."""
+ fake = {
+ "status": "checked",
+ "sei": _SEI,
+ "attestations": [
+ {
+ "kind": "operator_override",
+ "content_hash": "ch:x",
+ "recorded_at": None,
+ "seq": 1,
+ }
+ ],
+ }
+ monkeypatch.setattr(
+ "legis.service.governance.read_sei_attestations",
+ lambda records, sei: fake,
+ )
+ out = read_governance_for_sei([], _SEI)
+ assert out["records"] == []
+
+
+def test_as_of_non_rfc3339_is_omitted(monkeypatch):
+ """An attestation with a garbage recorded_at string must be OMITTED."""
+ fake = {
+ "status": "checked",
+ "sei": _SEI,
+ "attestations": [
+ {
+ "kind": "operator_override",
+ "content_hash": "ch:x",
+ "recorded_at": "garbage",
+ "seq": 1,
+ }
+ ],
+ }
+ monkeypatch.setattr(
+ "legis.service.governance.read_sei_attestations",
+ lambda records, sei: fake,
+ )
+ out = read_governance_for_sei([], _SEI)
+ assert out["records"] == []
+
+
+# ── unknown kind: omit, never crash ─────────────────────────────────────────
+
+
+def test_unknown_kind_is_omitted(monkeypatch):
+ """An attestation with an unrecognised kind must be OMITTED, not raise KeyError."""
+ fake = {
+ "status": "checked",
+ "sei": _SEI,
+ "attestations": [
+ {
+ "kind": "future_kind", # _POSTURE_BY_KIND.get returns None → omit
+ "content_hash": "ch:x",
+ "recorded_at": "2026-06-02T12:00:00+00:00",
+ "seq": 1,
+ }
+ ],
+ }
+ monkeypatch.setattr(
+ "legis.service.governance.read_sei_attestations",
+ lambda records, sei: fake,
+ )
+ out = read_governance_for_sei([], _SEI)
+ assert out["records"] == []
+
+
+# ── status propagation guard ─────────────────────────────────────────────────
+
+
+def test_status_propagated_not_hardcoded(monkeypatch):
+ """Happy path: read_sei_attestations status='checked' propagates through.
+ Guard path: a non-'checked' status raises AuditIntegrityError rather than
+ being silently relabelled 'checked'. Both branches must be exercised so the
+ guard is actually covered.
+ """
+ # Happy path: checked propagates
+ fake_checked = {"status": "checked", "sei": _SEI, "attestations": []}
+ monkeypatch.setattr(
+ "legis.service.governance.read_sei_attestations",
+ lambda records, sei: fake_checked,
+ )
+ out = read_governance_for_sei([], _SEI)
+ assert out["status"] == "checked"
+
+ # Guard path: a future non-checked status must not be silently relabelled
+ fake_other = {"status": "unavailable", "sei": _SEI, "attestations": []}
+ monkeypatch.setattr(
+ "legis.service.governance.read_sei_attestations",
+ lambda records, sei: fake_other,
+ )
+ with pytest.raises(AuditIntegrityError, match="unexpected status"):
+ read_governance_for_sei([], _SEI)
+
+
+# ── gate: signature tamper → AuditIntegrityError ────────────────────────────
+
+
+def test_gate_raises_on_signature_tamper(tmp_path):
+ """FORGE-A: mutate a signed field (judge_verdict) after the gate writes the
+ signature → TrailVerifier.verify raises TamperError → service gate wraps it
+ in AuditIntegrityError. Tests the service-gate wrapper, NOT a hand-rolled
+ TrailVerifier call (Constraint 6).
+ """
+ _, store, _ = _operator_override_gate(tmp_path)
+ records = store.read_all()
+ # Corrupt the signed verdict field (FORGE-A pattern from test_governance.py)
+ records[0].payload["extensions"]["judge_verdict"] = "BLOCKED"
+
+ with pytest.raises(AuditIntegrityError):
+ read_governance_for_sei_gate(
+ records,
+ _SEI,
+ hmac_key="protected-key",
+ protected_policies=frozenset({"protected.x"}),
+ )
+
+
+# ── gate: no key for protected records → ProtectedKeyRequiredError ───────────
+
+
+def test_gate_requires_key_for_protected(tmp_path):
+ """A protected record (protected_cell:True + judge_metadata_signature) present
+ without hmac_key → ProtectedKeyRequiredError, even when protected_policies is
+ empty (the discriminator fires on the signature marker, not the policy name).
+ """
+ _, store, _ = _operator_override_gate(tmp_path)
+ records = store.read_all()
+ with pytest.raises(ProtectedKeyRequiredError):
+ read_governance_for_sei_gate(
+ records,
+ _SEI,
+ hmac_key=None,
+ protected_policies=frozenset(),
+ )
+
+
+# ── unavailable helper shape ─────────────────────────────────────────────────
+
+
+def test_unavailable_helper_shape():
+ """governance_read_unavailable builds the discriminated unavailable envelope."""
+ out = governance_read_unavailable(_SEI, "trail not verifiable")
+ assert out == {
+ "status": "unavailable",
+ "sei": _SEI,
+ "records": [],
+ "unavailable": [{"reason": "trail not verifiable"}],
+ }
diff --git a/tests/service/test_preflight.py b/tests/service/test_preflight.py
index a7538c4..3de86e7 100644
--- a/tests/service/test_preflight.py
+++ b/tests/service/test_preflight.py
@@ -1,13 +1,42 @@
-from legis.service.preflight import read_warpline_preflight
+from legis.plainweave_preflight.client import PlainweaveError
+from legis.service.preflight import read_plainweave_preflight, read_warpline_preflight
from legis.warpline_preflight.client import WarplineError
+_IMPACT_ENVELOPE = {
+ "schema": "warpline.impact_radius.v1",
+ "ok": True,
+ "data": {"completeness": "FULL", "affected": [{"sei": "S1", "depth": 1}]},
+ "meta": {"local_only": True, "peer_side_effects": []},
+}
+
+_REVERIFY_ENVELOPE = {
+ "schema": "warpline.reverify_worklist.v1",
+ "ok": True,
+ "data": {"completeness": "FULL", "items": [{"sei": "S1", "reason": "edited"}]},
+ "meta": {"local_only": True, "peer_side_effects": []},
+}
+
+_EMPTY_REVERIFY = {
+ "schema": "warpline.reverify_worklist.v1",
+ "ok": True,
+ "data": {"completeness": "FULL", "items": []},
+ "meta": {"local_only": True, "peer_side_effects": []},
+}
+
+_EMPTY_IMPACT = {
+ "schema": "warpline.impact_radius.v1",
+ "ok": True,
+ "data": {"completeness": "FULL", "affected": []},
+ "meta": {"local_only": True, "peer_side_effects": []},
+}
+
class _OkWarpline:
def impact_radius(self, base, head):
- return {"affected": [{"sei": "S1"}], "count": 1}
+ return _IMPACT_ENVELOPE
def reverify_worklist(self, base, head):
- return {"entries": [{"sei": "S1", "reason": "edited"}], "count": 1}
+ return _REVERIFY_ENVELOPE
class _ImpactRaisesWarpline:
@@ -15,12 +44,12 @@ def impact_radius(self, base, head):
raise WarplineError("boom")
def reverify_worklist(self, base, head):
- return {"entries": [], "count": 0}
+ return _EMPTY_REVERIFY
class _WorklistRaisesWarpline:
def impact_radius(self, base, head):
- return {"affected": [], "count": 0}
+ return _EMPTY_IMPACT
def reverify_worklist(self, base, head):
raise WarplineError("timeout")
@@ -30,8 +59,8 @@ def test_checked_when_both_methods_succeed():
out = read_warpline_preflight(_OkWarpline(), "aaa", "bbb")
assert out == {
"status": "checked",
- "impact_radius": {"affected": [{"sei": "S1"}], "count": 1},
- "reverify_worklist": {"entries": [{"sei": "S1", "reason": "edited"}], "count": 1},
+ "impact_radius": _IMPACT_ENVELOPE,
+ "reverify_worklist": _REVERIFY_ENVELOPE,
}
@@ -61,3 +90,82 @@ def test_warpline_error_never_escapes_as_internal_error():
# The transport error is caught and converted, never re-raised.
out = read_warpline_preflight(_ImpactRaisesWarpline(), "aaa", "bbb")
assert out["status"] == "unavailable" # no exception propagated
+
+
+# ---------------------------------------------------------------------------
+# read_plainweave_preflight — advisory SIBLING of read_warpline_preflight.
+# ---------------------------------------------------------------------------
+
+_PLAINWEAVE_ENVELOPE = {
+ "schema": "weft.plainweave.preflight_facts.v1",
+ "ok": True,
+ "data": {
+ "freshness": "partial",
+ "facts": [],
+ "authority_boundary": {
+ "local_only": True,
+ "live_peer_calls": False,
+ "governance_verdicts": False,
+ },
+ },
+ "warnings": [],
+ "meta": {},
+}
+
+
+class _OkPlainweave:
+ def preflight_facts(self, base, head):
+ return _PLAINWEAVE_ENVELOPE
+
+
+class _RaisesPlainweave:
+ def preflight_facts(self, base, head):
+ raise PlainweaveError("boom")
+
+
+def test_plainweave_checked_when_method_succeeds():
+ out = read_plainweave_preflight(_OkPlainweave(), "aaa", "bbb")
+ assert out == {
+ "status": "checked",
+ "preflight_facts": _PLAINWEAVE_ENVELOPE,
+ }
+
+
+def test_plainweave_unavailable_when_client_is_none_not_a_silent_empty():
+ out = read_plainweave_preflight(None, "aaa", "bbb")
+ assert out["status"] == "unavailable"
+ assert out["unavailable"] == [{"reason": "plainweave client not configured"}]
+ # ASYMMETRIC: never an empty fact-set that reads as "nothing impacted".
+ assert "preflight_facts" not in out
+
+
+def test_plainweave_unavailable_when_preflight_raises_plainweave_error():
+ out = read_plainweave_preflight(_RaisesPlainweave(), "aaa", "bbb")
+ assert out["status"] == "unavailable"
+ assert out["unavailable"][0]["reason"].startswith("plainweave check failed:")
+
+
+def test_plainweave_error_never_escapes_as_internal_error():
+ # The transport/contract error is caught and converted, never re-raised.
+ out = read_plainweave_preflight(_RaisesPlainweave(), "aaa", "bbb")
+ assert out["status"] == "unavailable" # no exception propagated
+
+
+def test_plainweave_honest_degrade_on_error_envelope_from_uninitialized_project():
+ # The most likely real-world degrade: an uninitialized plainweave returns an
+ # ok:false error envelope (schema weft.plainweave.error.v1). The consumer's
+ # PlainweaveMcpClient._call rejects it -> PlainweaveError -> 'unavailable',
+ # NOT 'checked' and NOT a raised INTERNAL_ERROR.
+ from legis.plainweave_preflight.client import PlainweaveMcpClient
+
+ error_envelope = {
+ "schema": "weft.plainweave.error.v1",
+ "ok": False,
+ "error": {"code": "not_found", "message": "Plainweave project is not initialized"},
+ "warnings": [],
+ "meta": {},
+ }
+ client = PlainweaveMcpClient(invoke=lambda t, a: error_envelope, repo="/r")
+ out = read_plainweave_preflight(client, "aaa", "bbb")
+ assert out["status"] == "unavailable"
+ assert "preflight_facts" not in out
diff --git a/tests/warpline_preflight/fixtures/PROVENANCE.md b/tests/warpline_preflight/fixtures/PROVENANCE.md
index 1d590ec..f85edd7 100644
--- a/tests/warpline_preflight/fixtures/PROVENANCE.md
+++ b/tests/warpline_preflight/fixtures/PROVENANCE.md
@@ -1,43 +1,49 @@
# Warpline preflight golden — provenance
-`warpline-preflight-golden.json` freezes the two response shapes legis's real
-warpline preflight consumer parses:
+`warpline-preflight-golden.json` freezes the two envelope shapes warpline emits
+via its MCP surface, live-captured from `warpline-mcp` (version 1.2.0):
- * `GET /api/impact-radius` -> `{"affected": [{"sei": ...}, ...], "count": N}`
- * `GET /api/reverify-worklist` -> `{"entries": [{"sei": ...}, ...], "count": N}`
+ * `warpline_impact_radius_get` → envelope schema `warpline.impact_radius.v1`
+ * `warpline_reverify_worklist_get` → envelope schema `warpline.reverify_worklist.v1`
-These are the shapes `legis.warpline_preflight.client.HttpWarplineClient`
-(`impact_radius` / `reverify_worklist`) and `legis.service.preflight.read_warpline_preflight`
-expect today.
+Both captured against the legis repo, rev_range `HEAD~1..HEAD`, on 2026-06-27.
+The capture produced `completeness: "NO_SNAPSHOT"` and empty `affected`/`items`
+lists (no Loomweave snapshot in place at capture time). The meta fields
+`local_only: true, peer_side_effects: []` are present and valid.
-## NOT vendored from warpline — frozen to the legis-expected contract
+## Live-capture transcripts
-This golden is **frozen to the shape legis's client expects**, NOT vendored
-byte-identical from a warpline source fixture. As of this writing warpline ships
-**no producer** for these flat REST shapes:
+Both halves of the golden are backed by committed raw MCP session transcripts
+(three JSON-RPC lines each: id=1 initialize response, id=null
+notifications/initialized error, id=2 tools/call response):
- * warpline has **no HTTP server**; its surface is MCP/CLI only.
- * warpline's `impact_radius` / `reverify_worklist` commands return the rich
- envelope schemas `warpline.impact_radius.v1` / `warpline.reverify_worklist.v1`
- (`{schema, ok, query, data: {... affected / items ...}, enrichment, ...}`),
- where the affected set is nested under `data` and the reverify list is
- `data.items` — NOT the top-level flat `{"affected"/"entries", "count"}` legis
- parses. There is no top-level `count`.
- * warpline's only legis-facing seam (`federation.py`) runs the OPPOSITE
- direction: warpline CONSULTS legis governance as a federation peer.
+ * `warpline-mcp-live-session.jsonl` — impact_radius session, captured 2026-06-26
+ * `warpline-mcp-reverify-session.jsonl` — reverify_worklist session, captured 2026-06-27
-## WARPLINE PRODUCER-SIDE OBLIGATION
+Replay tests in `test_stdio_invoke.py` echo these raw bytes through `StdioMcpInvoke`
+and assert known fields from the capture, so the real message order + result shape
+(structuredContent vs content[].text + protocolVersion) are exercised, not just
+legis-shaped assumptions.
-For this seam to reach a shared, byte-identical golden, warpline must ship a
-producer (an HTTP `GET /api/impact-radius` + `GET /api/reverify-worklist`, or an
-equivalent flat-shape projection) emitting exactly:
+## Legis conforms to warpline's extant envelope — warpline ships no producer
- impact-radius : {"affected": [{"sei": "loomweave:eid:<32hex>", ...}], "count": N}
- reverify-worklist: {"entries": [{"sei": "loomweave:eid:<32hex>", ...}], "count": N}
+Per SEAM 4 §4A and GV-LG-3: legis is a CONSUMER of warpline's extant MCP
+envelope. Legis's `WarplineMcpClient._call` validates the envelope
+(schema/ok/meta/completeness) and passes it through verbatim. Warpline owns the
+`warpline.impact_radius.v1` / `warpline.reverify_worklist.v1` schemas; legis does
+not interpret or re-shape them.
-and vendor a contract fixture for it under
-`warpline/tests/fixtures/contracts/warpline/`. The Layer-2 recheck in
+Warpline ships no producer-side contract fixture for its MCP envelopes (no
+`mcp-preflight-golden.json`). The Layer-2 recheck in
`test_warpline_preflight_oracle.py` (`test_golden_matches_warpline_source`)
-points at that path and `pytest.skip`s until it exists; it activates
-automatically and will then enforce byte-equality (re-vendor + update the
-byte-pin once the producer ships).
+skips cleanly and will activate automatically if warpline vendors that fixture.
+
+## Re-capturing the golden
+
+Run `warpline-mcp` with stdio JSON-RPC calls for `warpline_impact_radius_get` and
+`warpline_reverify_worklist_get` (see `warpline-mcp-live-session.jsonl` and
+`warpline-mcp-reverify-session.jsonl` for the exact protocol), extract the
+`structuredContent` from each `id=2` response, build the golden as
+`{"impact_radius": <...>, "reverify_worklist": <...>, "_provenance":
+{"source": "live-captured", ...}}`, then re-pin `GOLDEN_BLOB_SHA` in the oracle
+to `git hash-object tests/warpline_preflight/fixtures/warpline-preflight-golden.json`.
diff --git a/tests/warpline_preflight/fixtures/warpline-mcp-live-session.jsonl b/tests/warpline_preflight/fixtures/warpline-mcp-live-session.jsonl
new file mode 100644
index 0000000..15b5ede
--- /dev/null
+++ b/tests/warpline_preflight/fixtures/warpline-mcp-live-session.jsonl
@@ -0,0 +1,3 @@
+{"jsonrpc": "2.0", "id": 1, "result": {"protocolVersion": "2025-03-26", "serverInfo": {"name": "warpline", "version": "1.2.0"}, "capabilities": {"tools": {}}, "instructions": "Use tools/list, then tools/call. Endorsed names and short shims return identical schema+data. Tool errors are structured in JSON-RPC error.data with schema warpline.error.v1 and a closed error_code/retryability vocab."}}
+{"jsonrpc": "2.0", "id": null, "error": {"code": -32601, "message": "notifications/initialized", "data": {"schema": "warpline.error.v1", "error_code": "missing_required_field", "retryability": "retry_with_changes", "hint": "Supply the required argument and retry the same tool.", "details": {"message": "unknown method"}, "rejected_field": "method"}}}
+{"jsonrpc": "2.0", "id": 2, "result": {"structuredContent": {"schema": "warpline.impact_radius.v1", "ok": true, "query": {"repo": "/home/john/legis", "tool": "warpline_impact_radius_get", "arguments": {"rev_range": "HEAD~1..HEAD", "changed_entity_key_ids": [], "depth": 2}, "filters": {}, "sort": {"by": "depth", "order": "asc"}, "page": {"limit": 100, "cursor": null}}, "data": {"completeness": "NO_SNAPSHOT", "staleness": {"snapshot_commit": null, "commits_behind": null}, "resolved": [], "unresolved": [], "changed": [], "affected": [], "overflow": {"total": 0, "returned": 0, "dumped_to": null, "reason_class": "clean"}, "page": {"limit": 100, "next_cursor": null, "has_more": false, "reason_class": "clean"}}, "warnings": ["NO_SNAPSHOT: downstream traversal unavailable; changed set only"], "next_actions": {}, "enrichment": {"sei": "absent", "edges": "absent", "work": "unavailable", "risk": "unavailable", "governance": "unavailable", "requirements": "unavailable"}, "enrichment_reasons": {"requirements": {"reason_class": "disabled", "cause": "the requirements dimension is reserved in the frozen enrichment vocab but no requirements-trace transport is wired in warpline yet", "fix": "wire a requirements-trace consumer (e.g. a legis/requirements read keyed on the SEI) and populate enrichment.requirements; until then it is honestly reserved, not an earned-empty"}}, "meta": {"producer": {"tool": "warpline", "version": "1.2.0"}, "local_only": true, "peer_side_effects": []}}, "content": [{"type": "text", "text": "{\"data\": {\"affected\": [], \"changed\": [], \"completeness\": \"NO_SNAPSHOT\", \"overflow\": {\"dumped_to\": null, \"reason_class\": \"clean\", \"returned\": 0, \"total\": 0}, \"page\": {\"has_more\": false, \"limit\": 100, \"next_cursor\": null, \"reason_class\": \"clean\"}, \"resolved\": [], \"staleness\": {\"commits_behind\": null, \"snapshot_commit\": null}, \"unresolved\": []}, \"enrichment\": {\"edges\": \"absent\", \"governance\": \"unavailable\", \"requirements\": \"unavailable\", \"risk\": \"unavailable\", \"sei\": \"absent\", \"work\": \"unavailable\"}, \"enrichment_reasons\": {\"requirements\": {\"cause\": \"the requirements dimension is reserved in the frozen enrichment vocab but no requirements-trace transport is wired in warpline yet\", \"fix\": \"wire a requirements-trace consumer (e.g. a legis/requirements read keyed on the SEI) and populate enrichment.requirements; until then it is honestly reserved, not an earned-empty\", \"reason_class\": \"disabled\"}}, \"meta\": {\"local_only\": true, \"peer_side_effects\": [], \"producer\": {\"tool\": \"warpline\", \"version\": \"1.2.0\"}}, \"next_actions\": {}, \"ok\": true, \"query\": {\"arguments\": {\"changed_entity_key_ids\": [], \"depth\": 2, \"rev_range\": \"HEAD~1..HEAD\"}, \"filters\": {}, \"page\": {\"cursor\": null, \"limit\": 100}, \"repo\": \"/home/john/legis\", \"sort\": {\"by\": \"depth\", \"order\": \"asc\"}, \"tool\": \"warpline_impact_radius_get\"}, \"schema\": \"warpline.impact_radius.v1\", \"warnings\": [\"NO_SNAPSHOT: downstream traversal unavailable; changed set only\"]}"}]}}
diff --git a/tests/warpline_preflight/fixtures/warpline-mcp-reverify-session.jsonl b/tests/warpline_preflight/fixtures/warpline-mcp-reverify-session.jsonl
new file mode 100644
index 0000000..8d38f79
--- /dev/null
+++ b/tests/warpline_preflight/fixtures/warpline-mcp-reverify-session.jsonl
@@ -0,0 +1,3 @@
+{"jsonrpc": "2.0", "id": 1, "result": {"protocolVersion": "2025-03-26", "serverInfo": {"name": "warpline", "version": "1.2.0"}, "capabilities": {"tools": {}}, "instructions": "Use tools/list, then tools/call. Endorsed names and short shims return identical schema+data. Tool errors are structured in JSON-RPC error.data with schema warpline.error.v1 and a closed error_code/retryability vocab."}}
+{"jsonrpc": "2.0", "id": null, "error": {"code": -32601, "message": "notifications/initialized", "data": {"schema": "warpline.error.v1", "error_code": "missing_required_field", "retryability": "retry_with_changes", "hint": "Supply the required argument and retry the same tool.", "details": {"message": "unknown method"}, "rejected_field": "method"}}}
+{"jsonrpc": "2.0", "id": 2, "result": {"structuredContent": {"schema": "warpline.reverify_worklist.v1", "ok": true, "query": {"repo": "/home/john/legis", "tool": "warpline_reverify_worklist_get", "arguments": {"rev_range": "HEAD~1..HEAD", "changed_entity_key_ids": [], "depth": 2}, "filters": {}, "sort": {"by": "priority", "order": "asc"}, "group_by": "none", "include_federation": false, "page": {"limit": 100, "cursor": null}}, "data": {"completeness": "NO_SNAPSHOT", "staleness": {"snapshot_commit": null, "commits_behind": null}, "verification_summary": {"fresh": 0, "stale": 0, "unverified": 0, "unavailable": 0, "local_source_configured": false}, "resolved": [], "unresolved": [], "items": [], "grouped": null, "next_actions": {"filigree": []}, "overflow": {"total": 0, "returned": 0, "dumped_to": null, "reason_class": "clean"}, "page": {"limit": 100, "next_cursor": null, "has_more": false, "reason_class": "clean"}}, "warnings": ["NO_SNAPSHOT: downstream traversal unavailable; changed set only"], "next_actions": {"filigree": []}, "enrichment": {"sei": "absent", "edges": "absent", "work": "unavailable", "risk": "unavailable", "governance": "unavailable", "requirements": "unavailable"}, "enrichment_reasons": {"requirements": {"reason_class": "disabled", "cause": "the requirements dimension is reserved in the frozen enrichment vocab but no requirements-trace transport is wired in warpline yet", "fix": "wire a requirements-trace consumer (e.g. a legis/requirements read keyed on the SEI) and populate enrichment.requirements; until then it is honestly reserved, not an earned-empty"}}, "meta": {"producer": {"tool": "warpline", "version": "1.2.0"}, "local_only": true, "peer_side_effects": []}}, "content": [{"type": "text", "text": "{\"data\": {\"completeness\": \"NO_SNAPSHOT\", \"grouped\": null, \"items\": [], \"next_actions\": {\"filigree\": []}, \"overflow\": {\"dumped_to\": null, \"reason_class\": \"clean\", \"returned\": 0, \"total\": 0}, \"page\": {\"has_more\": false, \"limit\": 100, \"next_cursor\": null, \"reason_class\": \"clean\"}, \"resolved\": [], \"staleness\": {\"commits_behind\": null, \"snapshot_commit\": null}, \"unresolved\": [], \"verification_summary\": {\"fresh\": 0, \"local_source_configured\": false, \"stale\": 0, \"unavailable\": 0, \"unverified\": 0}}, \"enrichment\": {\"edges\": \"absent\", \"governance\": \"unavailable\", \"requirements\": \"unavailable\", \"risk\": \"unavailable\", \"sei\": \"absent\", \"work\": \"unavailable\"}, \"enrichment_reasons\": {\"requirements\": {\"cause\": \"the requirements dimension is reserved in the frozen enrichment vocab but no requirements-trace transport is wired in warpline yet\", \"fix\": \"wire a requirements-trace consumer (e.g. a legis/requirements read keyed on the SEI) and populate enrichment.requirements; until then it is honestly reserved, not an earned-empty\", \"reason_class\": \"disabled\"}}, \"meta\": {\"local_only\": true, \"peer_side_effects\": [], \"producer\": {\"tool\": \"warpline\", \"version\": \"1.2.0\"}}, \"next_actions\": {\"filigree\": []}, \"ok\": true, \"query\": {\"arguments\": {\"changed_entity_key_ids\": [], \"depth\": 2, \"rev_range\": \"HEAD~1..HEAD\"}, \"filters\": {}, \"group_by\": \"none\", \"include_federation\": false, \"page\": {\"cursor\": null, \"limit\": 100}, \"repo\": \"/home/john/legis\", \"sort\": {\"by\": \"priority\", \"order\": \"asc\"}, \"tool\": \"warpline_reverify_worklist_get\"}, \"schema\": \"warpline.reverify_worklist.v1\", \"warnings\": [\"NO_SNAPSHOT: downstream traversal unavailable; changed set only\"]}"}]}}
diff --git a/tests/warpline_preflight/fixtures/warpline-preflight-golden.json b/tests/warpline_preflight/fixtures/warpline-preflight-golden.json
index 44bb515..777b858 100644
--- a/tests/warpline_preflight/fixtures/warpline-preflight-golden.json
+++ b/tests/warpline_preflight/fixtures/warpline-preflight-golden.json
@@ -1,28 +1,167 @@
{
"impact_radius": {
- "affected": [
- {
- "sei": "loomweave:eid:0123456789abcdef0123456789abcdef",
- "locator": "python:function:src/pkg/mod.py::fn",
- "depth": 1
- },
- {
- "sei": "loomweave:eid:fedcba9876543210fedcba9876543210",
- "locator": "python:function:src/pkg/other.py::helper",
+ "schema": "warpline.impact_radius.v1",
+ "ok": true,
+ "query": {
+ "repo": "/home/john/legis",
+ "tool": "warpline_impact_radius_get",
+ "arguments": {
+ "rev_range": "HEAD~1..HEAD",
+ "changed_entity_key_ids": [],
"depth": 2
+ },
+ "filters": {},
+ "sort": {
+ "by": "depth",
+ "order": "asc"
+ },
+ "page": {
+ "limit": 100,
+ "cursor": null
+ }
+ },
+ "data": {
+ "completeness": "NO_SNAPSHOT",
+ "staleness": {
+ "snapshot_commit": null,
+ "commits_behind": null
+ },
+ "resolved": [],
+ "unresolved": [],
+ "changed": [],
+ "affected": [],
+ "overflow": {
+ "total": 0,
+ "returned": 0,
+ "dumped_to": null,
+ "reason_class": "clean"
+ },
+ "page": {
+ "limit": 100,
+ "next_cursor": null,
+ "has_more": false,
+ "reason_class": "clean"
}
+ },
+ "warnings": [
+ "NO_SNAPSHOT: downstream traversal unavailable; changed set only"
],
- "count": 2
+ "next_actions": {},
+ "enrichment": {
+ "sei": "absent",
+ "edges": "absent",
+ "work": "unavailable",
+ "risk": "unavailable",
+ "governance": "unavailable",
+ "requirements": "unavailable"
+ },
+ "enrichment_reasons": {
+ "requirements": {
+ "reason_class": "disabled",
+ "cause": "the requirements dimension is reserved in the frozen enrichment vocab but no requirements-trace transport is wired in warpline yet",
+ "fix": "wire a requirements-trace consumer (e.g. a legis/requirements read keyed on the SEI) and populate enrichment.requirements; until then it is honestly reserved, not an earned-empty"
+ }
+ },
+ "meta": {
+ "producer": {
+ "tool": "warpline",
+ "version": "1.2.0"
+ },
+ "local_only": true,
+ "peer_side_effects": []
+ }
},
"reverify_worklist": {
- "entries": [
- {
- "sei": "loomweave:eid:0123456789abcdef0123456789abcdef",
- "locator": "python:function:src/pkg/mod.py::fn",
- "priority": "high",
- "reason": "changed"
+ "schema": "warpline.reverify_worklist.v1",
+ "ok": true,
+ "query": {
+ "repo": "/home/john/legis",
+ "tool": "warpline_reverify_worklist_get",
+ "arguments": {
+ "rev_range": "HEAD~1..HEAD",
+ "changed_entity_key_ids": [],
+ "depth": 2
+ },
+ "filters": {},
+ "sort": {
+ "by": "priority",
+ "order": "asc"
+ },
+ "group_by": "none",
+ "include_federation": false,
+ "page": {
+ "limit": 100,
+ "cursor": null
}
+ },
+ "data": {
+ "completeness": "NO_SNAPSHOT",
+ "staleness": {
+ "snapshot_commit": null,
+ "commits_behind": null
+ },
+ "verification_summary": {
+ "fresh": 0,
+ "stale": 0,
+ "unverified": 0,
+ "unavailable": 0,
+ "local_source_configured": false
+ },
+ "resolved": [],
+ "unresolved": [],
+ "items": [],
+ "grouped": null,
+ "next_actions": {
+ "filigree": []
+ },
+ "overflow": {
+ "total": 0,
+ "returned": 0,
+ "dumped_to": null,
+ "reason_class": "clean"
+ },
+ "page": {
+ "limit": 100,
+ "next_cursor": null,
+ "has_more": false,
+ "reason_class": "clean"
+ }
+ },
+ "warnings": [
+ "NO_SNAPSHOT: downstream traversal unavailable; changed set only"
],
- "count": 1
+ "next_actions": {
+ "filigree": []
+ },
+ "enrichment": {
+ "sei": "absent",
+ "edges": "absent",
+ "work": "unavailable",
+ "risk": "unavailable",
+ "governance": "unavailable",
+ "requirements": "unavailable"
+ },
+ "enrichment_reasons": {
+ "requirements": {
+ "reason_class": "disabled",
+ "cause": "the requirements dimension is reserved in the frozen enrichment vocab but no requirements-trace transport is wired in warpline yet",
+ "fix": "wire a requirements-trace consumer (e.g. a legis/requirements read keyed on the SEI) and populate enrichment.requirements; until then it is honestly reserved, not an earned-empty"
+ }
+ },
+ "meta": {
+ "producer": {
+ "tool": "warpline",
+ "version": "1.2.0"
+ },
+ "local_only": true,
+ "peer_side_effects": []
+ }
+ },
+ "_provenance": {
+ "source": "live-captured",
+ "captured": "2026-06-27",
+ "warpline_version": "1.2.0",
+ "repo": "/home/john/legis",
+ "rev_range": "HEAD~1..HEAD"
}
}
diff --git a/tests/warpline_preflight/test_client.py b/tests/warpline_preflight/test_client.py
index 7e04a88..9f6b46c 100644
--- a/tests/warpline_preflight/test_client.py
+++ b/tests/warpline_preflight/test_client.py
@@ -1,128 +1,73 @@
-import inspect
-import json
-
import pytest
+from legis.warpline_preflight.client import WarplineMcpClient, WarplineClient, WarplineError
+
+_VALID_META = {"local_only": True, "peer_side_effects": []}
+_KEEP = object() # sentinel: "use the valid default meta" — DISTINCT from None (None IS a test case)
+
-import legis.filigree.client as fc
-import legis.warpline_preflight.client as wc
-from legis.warpline_preflight.client import (
- HttpWarplineClient,
- WarplineClient,
- WarplineError,
- MAX_RESPONSE_BYTES,
-)
+def _env(schema, data_key, items, *, meta=_KEEP, completeness="FULL"):
+ data = {data_key: items, "staleness": {"commits_behind": 0}}
+ if completeness is not None:
+ data["completeness"] = completeness
+ return {"schema": schema, "ok": True, "query": {"rev_range": "aaa..bbb"}, "data": data,
+ "warnings": [], "next_actions": {}, "enrichment": {"sei": "present"},
+ "meta": dict(_VALID_META) if meta is _KEEP else meta} # meta=None -> {"meta": None}, a real case
def _recorder(responses):
- """An injectable Fetch that returns queued dicts and records calls."""
calls = []
- def fetch(method, url, body):
- calls.append((method, url, body))
+ def invoke(tool, arguments):
+ calls.append((tool, arguments))
return responses.pop(0)
- fetch.calls = calls
- return fetch
+ invoke.calls = calls
+ return invoke
def test_protocol_is_runtime_checkable():
- client = HttpWarplineClient("http://localhost:9100", fetch=_recorder([{}]))
- assert isinstance(client, WarplineClient)
+ assert isinstance(WarplineMcpClient(invoke=_recorder([{}]), repo="/tmp/r"), WarplineClient)
-def test_impact_radius_is_a_get_with_base_head_query():
- fetch = _recorder([{"affected": [], "count": 0}])
- client = HttpWarplineClient("http://localhost:9100", fetch=fetch)
- out = client.impact_radius("aaa", "bbb")
- assert out == {"affected": [], "count": 0}
- method, url, body = fetch.calls[0]
- assert method == "GET" and body is None
- assert url == "http://localhost:9100/api/impact-radius?base=aaa&head=bbb"
+def test_impact_radius_calls_tool_with_rev_range_and_passes_envelope_through():
+ e = _env("warpline.impact_radius.v1", "affected", [{"sei": "loomweave:eid:" + "a"*32}])
+ inv = _recorder([e]); out = WarplineMcpClient(invoke=inv, repo="/tmp/r").impact_radius("aaa", "bbb")
+ assert out == e
+ assert inv.calls[0] == ("warpline_impact_radius_get", {"repo": "/tmp/r", "rev_range": "aaa..bbb"})
-def test_reverify_worklist_is_a_get_with_base_head_query():
- fetch = _recorder([{"entries": [], "count": 0}])
- client = HttpWarplineClient("http://localhost:9100", fetch=fetch)
- out = client.reverify_worklist("aaa", "bbb")
- assert out == {"entries": [], "count": 0}
- method, url, body = fetch.calls[0]
- assert method == "GET" and body is None
- assert url == "http://localhost:9100/api/reverify-worklist?base=aaa&head=bbb"
+def test_reverify_calls_reverify_tool():
+ e = _env("warpline.reverify_worklist.v1", "items", [])
+ inv = _recorder([e]); WarplineMcpClient(invoke=inv, repo="/tmp/r").reverify_worklist("a", "b")
+ assert inv.calls[0][0] == "warpline_reverify_worklist_get"
-def test_non_object_response_is_a_warpline_error():
- client = HttpWarplineClient("http://localhost:9100", fetch=_recorder([["not", "a", "dict"]]))
+@pytest.mark.parametrize("bad", [["not", "dict"], "str", 7, None])
+def test_non_dict_envelope_is_warpline_error(bad):
with pytest.raises(WarplineError):
- client.impact_radius("a", "b")
-
-
-def test_loopback_http_ok_remote_http_rejected_unless_optin(monkeypatch):
- monkeypatch.delenv("LEGIS_ALLOW_INSECURE_REMOTE_HTTP", raising=False)
- HttpWarplineClient("http://127.0.0.1:9100") # loopback IP ok
- HttpWarplineClient("http://localhost:9100") # localhost ok
- HttpWarplineClient("https://warpline.example.com") # https ok
- with pytest.raises(WarplineError, match="HTTPS unless it is loopback"):
- HttpWarplineClient("http://warpline.example.com")
- monkeypatch.setenv("LEGIS_ALLOW_INSECURE_REMOTE_HTTP", "1")
- HttpWarplineClient("http://warpline.example.com") # opt-in permits it
-
-
-def test_base_url_must_be_http_with_host():
- with pytest.raises(WarplineError, match="http\\(s\\) URL with a host"):
- HttpWarplineClient("ftp://warpline")
- with pytest.raises(WarplineError, match="http\\(s\\) URL with a host"):
- HttpWarplineClient("not-a-url")
-
-
-def test_response_too_large_via_real_decode_path():
- # Exercise the real _decode_json_response size guard with a fake resp object.
- from legis.warpline_preflight.client import _decode_json_response
-
- big = json.dumps({"x": "y" * (MAX_RESPONSE_BYTES + 10)}).encode("utf-8")
-
- class _Resp:
- headers = {"Content-Type": "application/json"}
-
- def read(self, n):
- return big[:n]
-
- with pytest.raises(WarplineError, match="response too large"):
- _decode_json_response(_Resp(), "GET test")
-
-
-def test_non_json_content_type_rejected():
- from legis.warpline_preflight.client import _decode_json_response
-
- class _Resp:
- headers = {"Content-Type": "text/html"}
-
- def read(self, n):
- return b""
-
- with pytest.raises(WarplineError, match="non-JSON content type"):
- _decode_json_response(_Resp(), "GET test")
-
+ WarplineMcpClient(invoke=_recorder([bad]), repo="/tmp/r").impact_radius("a", "b")
-def test_no_redirect_handler_returns_none():
- from legis.warpline_preflight.client import _NoRedirectHandler
- h = _NoRedirectHandler()
- assert h.redirect_request(None, None, 302, "Found", {}, "http://elsewhere") is None
+def test_wrong_schema_or_not_ok_is_warpline_error():
+ wrong = _env("warpline.reverify_worklist.v1", "items", []) # wrong schema for impact
+ with pytest.raises(WarplineError, match="schema"):
+ WarplineMcpClient(invoke=_recorder([wrong]), repo="/tmp/r").impact_radius("a", "b")
+ notok = _env("warpline.impact_radius.v1", "affected", []); notok["ok"] = False
+ with pytest.raises(WarplineError, match="ok"):
+ WarplineMcpClient(invoke=_recorder([notok]), repo="/tmp/r").impact_radius("a", "b")
-def _normalize(src):
- # The ONLY intended differences are the sibling name and its error class.
- return src.replace("Filigree", "Warpline").replace("filigree", "warpline")
+def test_gv_lg_3_hostile_or_malformed_meta_is_refused_fail_closed():
+ e = _env("warpline.impact_radius.v1", "affected", []); e["meta"] = {"local_only": True, "peer_side_effects": ["did_a_thing"]}
+ with pytest.raises(WarplineError, match="side effect"):
+ WarplineMcpClient(invoke=_recorder([e]), repo="/tmp/r").impact_radius("a", "b")
+ for bad_meta in ({"local_only": False, "peer_side_effects": []}, {"peer_side_effects": []}, "not-a-dict", None, 5):
+ em = _env("warpline.impact_radius.v1", "affected", [], meta=bad_meta)
+ with pytest.raises(WarplineError): # non-dict / missing / False local_only all refuse
+ WarplineMcpClient(invoke=_recorder([em]), repo="/tmp/r").impact_radius("a", "b")
-def test_security_primitives_are_faithful_clones_of_filigree():
- # If a future patch hardens filigree's SSRF/redirect/DoS handling this fails
- # loudly so warpline is patched in lockstep. _urllib_fetch is EXCLUDED — it
- # intentionally drops filigree's weft_signing body bytes (warpline is GET-only).
- for name in ("_validate_base_url", "_is_loopback", "_open_no_redirect", "_decode_json_response"):
- assert _normalize(inspect.getsource(getattr(fc, name))) == inspect.getsource(
- getattr(wc, name)
- ), f"{name} diverged from the filigree clone"
- assert _normalize(inspect.getsource(fc._NoRedirectHandler)) == inspect.getsource(
- wc._NoRedirectHandler
- )
+def test_degraded_envelope_missing_completeness_is_warpline_error():
+ e = _env("warpline.impact_radius.v1", "affected", [], completeness=None) # completeness omitted
+ with pytest.raises(WarplineError, match="completeness"):
+ WarplineMcpClient(invoke=_recorder([e]), repo="/tmp/r").impact_radius("a", "b")
diff --git a/tests/warpline_preflight/test_stdio_invoke.py b/tests/warpline_preflight/test_stdio_invoke.py
new file mode 100644
index 0000000..c72a310
--- /dev/null
+++ b/tests/warpline_preflight/test_stdio_invoke.py
@@ -0,0 +1,182 @@
+"""Tests for StdioMcpInvoke — the production stdio JSON-RPC transport.
+
+Fault paths: every transport/parse fault must raise WarplineError (fail closed).
+DoD gate: test_replays_a_REAL_captured_session exercises bytes from a real
+warpline-mcp 1.2.0 session (see fixtures/PROVENANCE.md + warpline-mcp-live-session.jsonl).
+"""
+import pathlib
+import sys
+import textwrap
+
+import pytest
+
+from legis.warpline_preflight.client import StdioMcpInvoke, WarplineError
+
+
+def _script(tmp_path, body):
+ p = tmp_path / "fake.py"
+ p.write_text(textwrap.dedent(body))
+ return [sys.executable, str(p)]
+
+
+_OK = '''
+ import sys, json
+ for line in sys.stdin:
+ m = json.loads(line); mid = m.get("id")
+ if m.get("method") == "initialize":
+ print(json.dumps({"jsonrpc":"2.0","id":mid,"result":{"protocolVersion":"2025-06-18","capabilities":{},"serverInfo":{"name":"f","version":"0"}}}), flush=True)
+ elif m.get("method") == "tools/call":
+ env = {"schema":"warpline.impact_radius.v1","ok":True,"query":m["params"]["arguments"],"data":{"affected":[{"sei":"x"}],"completeness":"FULL","staleness":{"commits_behind":0}},"warnings":[],"next_actions":{},"enrichment":{},"meta":{"local_only":True,"peer_side_effects":[]}}
+ print(json.dumps({"jsonrpc":"2.0","id":mid,"result":{"content":[{"type":"text","text":"{}"}],"structuredContent":env,"isError":False}}), flush=True)
+'''
+
+
+def test_round_trips_against_fake_server(tmp_path):
+ env = StdioMcpInvoke(command=_script(tmp_path, _OK))("warpline_impact_radius_get", {"rev_range": "a..b"})
+ assert env["data"]["affected"] == [{"sei": "x"}]
+
+
+def test_replays_a_REAL_captured_session(tmp_path):
+ """DoD GATE: this fixture is bytes captured from a live `warpline-mcp` session
+ (see PROVENANCE). A green here means the real message order + result shape
+ (structuredContent vs content[].text + protocolVersion) were exercised, not a
+ legis-shaped assumption. If no live capture exists, this test FAILS (xfail is
+ not allowed) and Task 2 is not done — escalate (see the gate note).
+
+ PROVENANCE: captured from warpline-mcp 1.2.0 on 2026-06-26 in /home/john/legis.
+ Exit-on-EOF: YES (rc=0). Interleaved I/O: NOT required (batch accepted).
+ Real protocolVersion from server: "2025-03-26".
+ Result shape: structuredContent (dict) present; notifications/initialized
+ returns id:null error (valid JSON, id != 2 -> silently skipped by _read_jsonrpc_result).
+ NOTE: warpline_impact_radius_get requires a "repo" arg; WarplineMcpClient (Task 1)
+ does not yet supply it — flagged as a Task-1/Task-3 concern, not fixed here.
+ """
+ fixture = pathlib.Path(__file__).parent / "fixtures" / "warpline-mcp-live-session.jsonl"
+ assert fixture.exists(), f"live session fixture missing: {fixture}"
+ stub = tmp_path / "replay.py"
+ stub.write_text(textwrap.dedent(f"""
+ import sys
+ sys.stdin.read() # consume all stdin to EOF before responding
+ with open({str(fixture)!r}) as fh:
+ for line in fh:
+ line = line.rstrip('\\n')
+ if line:
+ print(line, flush=True)
+ """))
+ env = StdioMcpInvoke(command=[sys.executable, str(stub)])(
+ "warpline_impact_radius_get", {"rev_range": "HEAD~1..HEAD", "repo": "/test"}
+ )
+ # assertions on known fields from the real captured envelope
+ assert env["schema"] == "warpline.impact_radius.v1"
+ assert env["ok"] is True
+ assert env["data"]["completeness"] == "NO_SNAPSHOT"
+ assert env["meta"]["local_only"] is True
+ assert env["meta"]["peer_side_effects"] == []
+
+
+def test_replays_a_REAL_reverify_session(tmp_path):
+ """DoD GATE: this fixture is bytes captured from a live `warpline-mcp` session
+ for the `warpline_reverify_worklist_get` tool (see PROVENANCE). A green here means
+ the real message order + result shape were exercised for the reverify tool path.
+
+ PROVENANCE: captured from warpline-mcp 1.2.0 on 2026-06-27 in /home/john/legis.
+ Exit-on-EOF: YES (rc=0). Interleaved I/O: NOT required (batch accepted).
+ Real protocolVersion from server: "2025-03-26".
+ Result shape: structuredContent (dict) present; notifications/initialized
+ returns id:null error (valid JSON, id != 2 -> silently skipped by _read_jsonrpc_result).
+ """
+ fixture = pathlib.Path(__file__).parent / "fixtures" / "warpline-mcp-reverify-session.jsonl"
+ assert fixture.exists(), f"live reverify session fixture missing: {fixture}"
+ stub = tmp_path / "replay.py"
+ stub.write_text(textwrap.dedent(f"""
+ import sys
+ sys.stdin.read() # consume all stdin to EOF before responding
+ with open({str(fixture)!r}) as fh:
+ for line in fh:
+ line = line.rstrip('\\n')
+ if line:
+ print(line, flush=True)
+ """))
+ env = StdioMcpInvoke(command=[sys.executable, str(stub)])(
+ "warpline_reverify_worklist_get", {"rev_range": "HEAD~1..HEAD", "repo": "/test"}
+ )
+ # assertions on known fields from the real captured envelope
+ assert env["schema"] == "warpline.reverify_worklist.v1"
+ assert env["ok"] is True
+ assert env["data"]["completeness"] == "NO_SNAPSHOT"
+ assert env["data"]["items"] == []
+ assert env["meta"]["local_only"] is True
+ assert env["meta"]["peer_side_effects"] == []
+
+
+def test_content_text_fallback_parse(tmp_path):
+ """MINOR (c): test the content[0].text fallback parse path.
+ When structuredContent is absent, StdioMcpInvoke must parse the envelope
+ from content[0].text. Both existing replay stubs return structuredContent;
+ this test exercises the fallback branch with a fake server that returns ONLY
+ content:[{type:text,text:}] and NO structuredContent.
+ """
+ import json as _json
+ envelope = {
+ "schema": "warpline.impact_radius.v1",
+ "ok": True,
+ "query": {},
+ "data": {"completeness": "FULL", "affected": [{"sei": "test-sei"}],
+ "staleness": {"commits_behind": 0}},
+ "warnings": [],
+ "next_actions": {},
+ "enrichment": {},
+ "meta": {"local_only": True, "peer_side_effects": [], "producer": {"tool": "warpline", "version": "0"}},
+ }
+ # Write envelope to a file so the fake server can read it without quoting hell
+ env_file = tmp_path / "envelope.json"
+ env_file.write_text(_json.dumps(envelope), encoding="utf-8")
+ body = f'''
+ import sys, json
+ env_text = open({str(env_file)!r}, encoding="utf-8").read()
+ for line in sys.stdin:
+ m = json.loads(line); mid = m.get("id")
+ if m.get("method") == "initialize":
+ print(json.dumps({{"jsonrpc":"2.0","id":mid,"result":{{"protocolVersion":"2025-06-18","capabilities":{{}},"serverInfo":{{"name":"f","version":"0"}}}}}}), flush=True)
+ elif m.get("method") == "tools/call":
+ print(json.dumps({{"jsonrpc":"2.0","id":mid,"result":{{"content":[{{"type":"text","text":env_text}}],"isError":False}}}}), flush=True)
+ '''
+ env = StdioMcpInvoke(command=_script(tmp_path, body))("warpline_impact_radius_get", {"rev_range": "a..b"})
+ assert env["schema"] == "warpline.impact_radius.v1"
+ assert env["data"]["affected"] == [{"sei": "test-sei"}]
+ assert env["data"]["completeness"] == "FULL"
+
+
+@pytest.mark.parametrize("body,match", [
+ ('import sys\n', "no JSON-RPC response"), # empty stdout
+ ('print("not json", flush=True)\n', "non-JSON line"), # non-JSON line
+ ('import json;print(json.dumps({"jsonrpc":"2.0","id":2,"result":7}))\n', "result"), # scalar result
+ ('import json;print(json.dumps({"jsonrpc":"2.0","id":2,"result":{"isError":True,"content":[]}}))\n', "error result"), # isError
+ ('import json;print(json.dumps({"jsonrpc":"2.0","id":2,"error":{"code":-1,"message":"boom"}}))\n', "boom"), # jsonrpc error
+])
+def test_fault_paths_all_raise_warpline_error(tmp_path, body, match):
+ with pytest.raises(WarplineError, match=match):
+ StdioMcpInvoke(command=_script(tmp_path, body))("warpline_impact_radius_get", {"rev_range": "a..b"})
+
+
+def test_missing_executable_is_warpline_error(tmp_path):
+ with pytest.raises(WarplineError):
+ StdioMcpInvoke(command=[str(tmp_path / "nope")])("warpline_impact_radius_get", {"rev_range": "a..b"})
+
+
+def test_empty_command_is_warpline_error():
+ with pytest.raises(WarplineError, match="empty"):
+ StdioMcpInvoke(command=[])("warpline_impact_radius_get", {"rev_range": "a..b"})
+
+
+def test_timeout_is_warpline_error(tmp_path):
+ with pytest.raises(WarplineError):
+ StdioMcpInvoke(command=_script(tmp_path, "import time;time.sleep(5)\n"), timeout=0.3)(
+ "warpline_impact_radius_get", {"rev_range": "a..b"}
+ )
+
+
+def test_oversize_stdout_is_warpline_error(tmp_path):
+ body = 'import sys;sys.stdout.buffer.write(b"x"*2_000_000)\n'
+ with pytest.raises(WarplineError, match="too large"):
+ StdioMcpInvoke(command=_script(tmp_path, body))("warpline_impact_radius_get", {"rev_range": "a..b"})
diff --git a/tests/warpline_preflight/test_warpline_preflight_oracle.py b/tests/warpline_preflight/test_warpline_preflight_oracle.py
index 986dab7..b291659 100644
--- a/tests/warpline_preflight/test_warpline_preflight_oracle.py
+++ b/tests/warpline_preflight/test_warpline_preflight_oracle.py
@@ -1,28 +1,27 @@
"""Weft warpline-preflight conformance oracle — Legis as consumer.
Legis reads warpline's ADVISORY preflight surface via
-``legis.warpline_preflight.client.HttpWarplineClient`` and
+``legis.warpline_preflight.client.WarplineMcpClient`` and
``legis.service.preflight.read_warpline_preflight``. This oracle freezes the two
-response shapes legis parses and drives legis's REAL parse path over the frozen
-bytes, so a shape change fails CI until legis updates the consumer.
+real envelope shapes warpline emits (``warpline.impact_radius.v1`` /
+``warpline.reverify_worklist.v1``, live-captured via MCP) and drives legis's REAL
+parse path over the frozen bytes, so a shape change fails CI until legis updates
+the consumer.
Three layers, mirroring ``tests/conformance/test_sei_oracle*``:
* Layer-1 byte-pin (``test_golden_byte_pin``): UNMARKED, default-suite,
recomputes the git blob sha1 in-process and fails CLOSED on any byte drift.
- * Non-circular consumer oracle (``test_*_drives_real_legis_parse``): the frozen
- golden BYTES flow through legis's real ``_decode_json_response`` (only the HTTP
- transport is stubbed, never the parse logic) and through the real
- ``read_warpline_preflight``; assertions are on HARDCODED SEIs/counts, never a
- re-parse of the golden.
- * Layer-2 source recheck (``test_golden_matches_warpline_source``): compares the
- frozen golden to warpline's source contract fixture; SKIPS CLEAN when absent
- and names the producer obligation.
-
-PROVENANCE: this golden is frozen to the shape legis's client expects, NOT
-vendored from warpline. Warpline ships no producer for these flat REST shapes
-today (no HTTP server; its surface is the rich ``warpline.impact_radius.v1`` /
-``warpline.reverify_worklist.v1`` envelope). See ``fixtures/PROVENANCE.md``.
+ * Non-circular consumer oracle (``test_golden_flows_through_the_real_parser``):
+ the frozen golden BYTES flow through legis's real ``WarplineMcpClient._call``
+ (schema/ok/meta/completeness validation) via a fake invoke replaying the
+ golden; assertions are HARDCODED literals, NEVER a re-parse of the golden.
+ * Layer-2 source recheck (``test_golden_matches_warpline_source``): compares
+ the frozen golden to warpline's MCP contract fixture; SKIPS CLEAN when absent
+ and notes the seam.
+
+PROVENANCE: this golden is live-captured from ``warpline-mcp`` (version 1.2.0,
+legis repo, HEAD~1..HEAD). See ``fixtures/PROVENANCE.md``.
"""
from __future__ import annotations
@@ -34,19 +33,17 @@
import pytest
from legis.service.preflight import read_warpline_preflight
-from legis.warpline_preflight.client import (
- HttpWarplineClient,
- _decode_json_response,
-)
+from legis.warpline_preflight.client import WarplineMcpClient
-GOLDEN_PATH = Path(__file__).parent / "fixtures" / "warpline-preflight-golden.json"
+FIX = Path(__file__).parent / "fixtures"
+GOLDEN_PATH = FIX / "warpline-preflight-golden.json"
# git blob sha1 of tests/warpline_preflight/fixtures/warpline-preflight-golden.json.
-# Frozen to the shape legis's warpline preflight client expects (NOT vendored
-# from warpline — warpline ships no producer for these flat REST shapes). Update
-# only after confirming the legis consumer contract intentionally changed; if
-# warpline later ships a producer fixture, re-vendor byte-identical and re-pin.
-GOLDEN_BLOB_SHA = "44bb515d528fdaca5b12703a896f55cd96c2483b"
+# Frozen to the live-captured warpline.impact_radius.v1 / warpline.reverify_worklist.v1
+# envelopes. Update ONLY after a deliberate re-capture from a live warpline-mcp run;
+# re-running warpline-mcp and updating this pin is the trigger for a consumer-contract
+# review. See fixtures/PROVENANCE.md.
+GOLDEN_BLOB_SHA = "777b85895076a622f5b0fbf734fa8265d8d49f36"
# ---------------------------------------------------------------------------
@@ -60,98 +57,109 @@ def test_golden_byte_pin():
data = GOLDEN_PATH.read_bytes()
assert _git_blob_sha1(data) == GOLDEN_BLOB_SHA, (
"warpline-preflight golden has drifted from its pinned bytes; update "
- "GOLDEN_BLOB_SHA only after confirming the legis consumer contract change "
- "is intended (and re-check the warpline producer obligation in PROVENANCE.md)."
+ "GOLDEN_BLOB_SHA only after a deliberate re-capture from warpline-mcp "
+ "(re-check PROVENANCE.md and re-run `git hash-object` on the new golden)."
)
# ---------------------------------------------------------------------------
-# Non-circular consumer oracle: golden BYTES -> legis's real decode -> real
-# read_warpline_preflight. Only the HTTP transport is stubbed.
+# Machine-readable provenance guard: golden must not be 'pending-live-capture'
+# in CI unless the explicit escape env var is set.
# ---------------------------------------------------------------------------
-class _GoldenResp:
- """A minimal urllib-response stand-in carrying the golden bytes for one route."""
-
- def __init__(self, raw: bytes) -> None:
- self.headers = {"Content-Type": "application/json"}
- self._raw = raw
-
- def read(self, n: int) -> bytes:
- return self._raw[:n]
-
-
-def _golden() -> dict:
- # Used ONLY to slice the two route bodies out of the single golden file so
- # each can be re-serialized to bytes and pushed through legis's real decode.
- # Assertions below never read from this — they are hardcoded.
- return json.loads(GOLDEN_PATH.read_bytes())
-
-
-def _bytes_fetch():
- """An injectable Fetch that serves the golden route bytes through legis's REAL
- ``_decode_json_response`` (parse logic is NOT stubbed — only transport is)."""
- golden = _golden()
- bodies = {
- "/api/impact-radius": json.dumps(golden["impact_radius"]).encode("utf-8"),
- "/api/reverify-worklist": json.dumps(golden["reverify_worklist"]).encode("utf-8"),
- }
-
- def fetch(method, url, body):
- assert method == "GET" and body is None
- for route, raw in bodies.items():
- if route in url:
- return _decode_json_response(_GoldenResp(raw), f"{method} {url}")
- raise AssertionError(f"unexpected warpline route in oracle: {url}")
-
- return fetch
-
-
-def _client() -> HttpWarplineClient:
- return HttpWarplineClient("http://localhost:9100", fetch=_bytes_fetch())
-
-
-def test_impact_radius_drives_real_legis_parse():
- out = _client().impact_radius("base-sha", "head-sha")
- assert out["count"] == 2
- assert [a["sei"] for a in out["affected"]] == [
- "loomweave:eid:0123456789abcdef0123456789abcdef",
- "loomweave:eid:fedcba9876543210fedcba9876543210",
- ]
-
+def test_golden_provenance_is_live_captured():
+ golden = json.loads(GOLDEN_PATH.read_bytes())
+ source = golden.get("_provenance", {}).get("source")
+ if source == "pending-live-capture" and not os.environ.get("LEGIS_WARPLINE_GOLDEN_PENDING_OK"):
+ pytest.fail(
+ "warpline-preflight golden is marked 'pending-live-capture' — "
+ "run a live warpline-mcp capture to produce a real golden, or set "
+ "LEGIS_WARPLINE_GOLDEN_PENDING_OK=1 only as a temporary escape during "
+ "bootstrap. See fixtures/PROVENANCE.md."
+ )
-def test_reverify_worklist_drives_real_legis_parse():
- out = _client().reverify_worklist("base-sha", "head-sha")
- assert out["count"] == 1
- assert [e["sei"] for e in out["entries"]] == [
- "loomweave:eid:0123456789abcdef0123456789abcdef"
- ]
+# ---------------------------------------------------------------------------
+# Non-circular consumer oracle: golden BYTES -> WarplineMcpClient._call
+# (schema/ok/meta/completeness validated). Assertions are HARDCODED literals
+# from the frozen golden — NEVER a re-parse of the golden bytes.
+# ---------------------------------------------------------------------------
-def test_read_warpline_preflight_over_golden_is_checked_with_real_shapes():
- # The full service read: discriminated 'checked' with both sub-responses,
- # parsed through legis's real client + decode over the frozen golden bytes.
- result = read_warpline_preflight(_client(), "base-sha", "head-sha")
+def _dispatch_invoke(golden: dict):
+ """Return an invoke that replays frozen envelopes keyed by tool name."""
+ def invoke(tool: str, args: dict):
+ if tool == "warpline_impact_radius_get":
+ return golden["impact_radius"]
+ if tool == "warpline_reverify_worklist_get":
+ return golden["reverify_worklist"]
+ raise AssertionError(f"unexpected tool in oracle: {tool!r}")
+ return invoke
+
+
+def test_golden_flows_through_the_real_parser_with_hardcoded_assertions():
+ """Drive the FROZEN golden bytes through WarplineMcpClient._call (the real
+ schema/ok/meta/completeness validation), via a fake invoke replaying the bytes.
+ Assert HARDCODED values from the golden — NEVER a re-parse of the golden."""
+ golden = json.loads(GOLDEN_PATH.read_bytes())
+
+ # --- impact_radius ---
+ impact = WarplineMcpClient(
+ invoke=lambda t, a: golden["impact_radius"], repo="/r"
+ ).impact_radius("b", "h")
+ # Hardcoded schema — the real parse gate; any envelope rename breaks here.
+ assert impact["schema"] == "warpline.impact_radius.v1"
+ assert impact["ok"] is True
+ # Hardcoded meta fields validated by _call (GV-LG-3 boundary).
+ assert impact["meta"]["local_only"] is True
+ assert impact["meta"]["peer_side_effects"] == []
+ assert impact["meta"]["producer"]["tool"] == "warpline"
+ # Hardcoded data shape — completeness and empty affected from the live capture.
+ assert impact["data"]["completeness"] == "NO_SNAPSHOT"
+ assert impact["data"]["affected"] == []
+
+ # --- reverify_worklist ---
+ reverify = WarplineMcpClient(
+ invoke=lambda t, a: golden["reverify_worklist"], repo="/r"
+ ).reverify_worklist("b", "h")
+ # Hardcoded schema.
+ assert reverify["schema"] == "warpline.reverify_worklist.v1"
+ assert reverify["ok"] is True
+ # Hardcoded meta fields.
+ assert reverify["meta"]["local_only"] is True
+ assert reverify["meta"]["peer_side_effects"] == []
+ assert reverify["meta"]["producer"]["tool"] == "warpline"
+ # Hardcoded data shape.
+ assert reverify["data"]["completeness"] == "NO_SNAPSHOT"
+ assert reverify["data"]["items"] == []
+
+
+def test_read_warpline_preflight_over_golden_is_checked():
+ """The full service read: discriminated 'checked' with both sub-responses,
+ parsed through legis's real WarplineMcpClient._call over the frozen golden bytes.
+ Assertions are hardcoded — not a re-parse of the golden."""
+ golden = json.loads(GOLDEN_PATH.read_bytes())
+ client = WarplineMcpClient(invoke=_dispatch_invoke(golden), repo="/r")
+ result = read_warpline_preflight(client, "base-sha", "head-sha")
+
+ # Hardcoded status discriminant.
assert result["status"] == "checked"
- assert result["impact_radius"]["count"] == 2
- assert [a["sei"] for a in result["impact_radius"]["affected"]] == [
- "loomweave:eid:0123456789abcdef0123456789abcdef",
- "loomweave:eid:fedcba9876543210fedcba9876543210",
- ]
- assert result["reverify_worklist"]["count"] == 1
- assert [e["sei"] for e in result["reverify_worklist"]["entries"]] == [
- "loomweave:eid:0123456789abcdef0123456789abcdef"
- ]
+ # Hardcoded sub-response schema and meta — the boundary validation was real.
+ assert result["impact_radius"]["schema"] == "warpline.impact_radius.v1"
+ assert result["impact_radius"]["meta"]["local_only"] is True
+ assert result["impact_radius"]["data"]["completeness"] == "NO_SNAPSHOT"
+ assert result["impact_radius"]["data"]["affected"] == []
+ assert result["reverify_worklist"]["schema"] == "warpline.reverify_worklist.v1"
+ assert result["reverify_worklist"]["meta"]["local_only"] is True
+ assert result["reverify_worklist"]["data"]["completeness"] == "NO_SNAPSHOT"
+ assert result["reverify_worklist"]["data"]["items"] == []
# ---------------------------------------------------------------------------
-# Layer-2: drift recheck vs warpline's source contract fixture (skip-clean).
+# Layer-2: drift recheck vs warpline's source MCP contract fixture (skip-clean).
#
-# Warpline ships NO producer for these flat REST shapes today (no HTTP server;
-# its real surface is the rich warpline.impact_radius.v1 / .reverify_worklist.v1
-# envelope, where the affected set is nested under data.affected / data.items and
-# there is no top-level count). So this recheck SKIPS CLEAN and names the
-# producer obligation. It activates byte-equality enforcement automatically the
-# day warpline ships a flat-shape contract fixture at the path below.
+# Warpline ships MCP tools with schema warpline.impact_radius.v1 /
+# warpline.reverify_worklist.v1 — no flat REST projection. When warpline vendors
+# a canonical contract fixture for these envelopes, point WARPLINE_REPO or place
+# a sibling checkout so this check enforces byte-equality automatically.
# ---------------------------------------------------------------------------
def _warpline_source_fixture() -> Path | None:
candidates: list[Path] = []
@@ -162,7 +170,7 @@ def _warpline_source_fixture() -> Path | None:
/ "fixtures"
/ "contracts"
/ "warpline"
- / "preflight-rest-golden.json"
+ / "mcp-preflight-golden.json"
)
candidates.append(
Path(__file__).resolve().parents[3]
@@ -171,7 +179,7 @@ def _warpline_source_fixture() -> Path | None:
/ "fixtures"
/ "contracts"
/ "warpline"
- / "preflight-rest-golden.json"
+ / "mcp-preflight-golden.json"
)
return next((path for path in candidates if path.exists()), None)
@@ -180,15 +188,13 @@ def test_golden_matches_warpline_source():
source = _warpline_source_fixture()
if source is None:
pytest.skip(
- "warpline ships no flat-REST preflight contract fixture "
- "(preflight-rest-golden.json) — its surface is the rich "
- "warpline.impact_radius.v1 / .reverify_worklist.v1 envelope, not the "
- "flat {affected/entries, count} shape legis consumes. PRODUCER "
- "OBLIGATION: warpline must ship GET /api/impact-radius + "
- "GET /api/reverify-worklist (or an equivalent flat projection) and "
- "vendor that fixture; set WARPLINE_REPO or place a sibling warpline "
- "checkout to enable this drift check. See fixtures/PROVENANCE.md."
+ "warpline ships no MCP contract fixture (mcp-preflight-golden.json) "
+ "at the expected path — the legis golden is live-captured from "
+ "warpline-mcp and conforms to the warpline.impact_radius.v1 / "
+ "warpline.reverify_worklist.v1 envelope (SEAM 4 §4A + GV-LG-3). "
+ "Set WARPLINE_REPO or place a sibling warpline checkout to enable "
+ "this drift check. See fixtures/PROVENANCE.md."
)
assert json.loads(GOLDEN_PATH.read_bytes()) == json.loads(
source.read_text(encoding="utf-8")
- ), "legis warpline-preflight golden drifted from warpline's source contract fixture"
+ ), "legis warpline-preflight golden drifted from warpline's source MCP contract fixture"
diff --git a/uv.lock b/uv.lock
index 7604c90..41b03aa 100644
--- a/uv.lock
+++ b/uv.lock
@@ -37,6 +37,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" },
]
+[[package]]
+name = "arrow"
+version = "1.4.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "python-dateutil" },
+ { name = "tzdata" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/b9/33/032cdc44182491aa708d06a68b62434140d8c50820a087fac7af37703357/arrow-1.4.0.tar.gz", hash = "sha256:ed0cc050e98001b8779e84d461b0098c4ac597e88704a655582b21d116e526d7", size = 152931, upload-time = "2025-10-18T17:46:46.761Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ed/c9/d7977eaacb9df673210491da99e6a247e93df98c715fc43fd136ce1d3d33/arrow-1.4.0-py3-none-any.whl", hash = "sha256:749f0769958ebdc79c173ff0b0670d59051a535fa26e8eba02953dc19eb43205", size = 68797, upload-time = "2025-10-18T17:46:45.663Z" },
+]
+
[[package]]
name = "ast-serialize"
version = "0.5.0"
@@ -323,6 +336,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e0/82/45359b62a067409bd929ae8a56b8ed13e5a8c8a61194b3c236920999ab83/fastapi-0.136.3-py3-none-any.whl", hash = "sha256:3d2a69bdf04b7e9f3afa292c3bc7a98816bbfafa10bc9b45f3f3700d2f761620", size = 117481, upload-time = "2026-05-23T18:53:16.924Z" },
]
+[[package]]
+name = "fqdn"
+version = "1.5.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/30/3e/a80a8c077fd798951169626cde3e239adeba7dab75deb3555716415bd9b0/fqdn-1.5.1.tar.gz", hash = "sha256:105ed3677e767fb5ca086a0c1f4bb66ebc3c100be518f0e0d755d9eae164d89f", size = 6015, upload-time = "2021-03-11T07:16:29.08Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/cf/58/8acf1b3e91c58313ce5cb67df61001fc9dcd21be4fadb76c1a2d540e09ed/fqdn-1.5.1-py3-none-any.whl", hash = "sha256:3a179af3761e4df6eb2e026ff9e1a3033d3587bf980a0b1b2e1e5d08d7358014", size = 9121, upload-time = "2021-03-11T07:16:28.351Z" },
+]
+
[[package]]
name = "greenlet"
version = "3.5.1"
@@ -469,6 +491,27 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
+[[package]]
+name = "isoduration"
+version = "20.11.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "arrow" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/7c/1a/3c8edc664e06e6bd06cce40c6b22da5f1429aa4224d0c590f3be21c91ead/isoduration-20.11.0.tar.gz", hash = "sha256:ac2f9015137935279eac671f94f89eb00584f940f5dc49462a0c4ee692ba1bd9", size = 11649, upload-time = "2020-11-01T11:00:00.312Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/7b/55/e5326141505c5d5e34c5e0935d2908a74e4561eca44108fbfb9c13d2911a/isoduration-20.11.0-py3-none-any.whl", hash = "sha256:b2904c2a4228c3d44f409c8ae8e2370eb21a26f7ac2ec5446df141dde3452042", size = 11321, upload-time = "2020-11-01T10:59:58.02Z" },
+]
+
+[[package]]
+name = "jsonpointer"
+version = "3.1.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/18/c7/af399a2e7a67fd18d63c40c5e62d3af4e67b836a2107468b6a5ea24c4304/jsonpointer-3.1.1.tar.gz", hash = "sha256:0b801c7db33a904024f6004d526dcc53bbb8a4a0f4e32bfd10beadf60adf1900", size = 9068, upload-time = "2026-03-23T22:32:32.458Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/9e/6a/a83720e953b1682d2d109d3c2dbb0bc9bf28cc1cbc205be4ef4be5da709d/jsonpointer-3.1.1-py3-none-any.whl", hash = "sha256:8ff8b95779d071ba472cf5bc913028df06031797532f08a7d5b602d8b2a488ca", size = 7659, upload-time = "2026-03-23T22:32:31.568Z" },
+]
+
[[package]]
name = "jsonschema"
version = "4.26.0"
@@ -484,6 +527,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" },
]
+[package.optional-dependencies]
+format = [
+ { name = "fqdn" },
+ { name = "idna" },
+ { name = "isoduration" },
+ { name = "jsonpointer" },
+ { name = "rfc3339-validator" },
+ { name = "rfc3987" },
+ { name = "uri-template" },
+ { name = "webcolors" },
+]
+
[[package]]
name = "jsonschema-specifications"
version = "2025.9.1"
@@ -498,7 +553,7 @@ wheels = [
[[package]]
name = "legis"
-version = "1.2.0"
+version = "1.3.0"
source = { editable = "." }
dependencies = [
{ name = "cryptography" },
@@ -512,7 +567,7 @@ dependencies = [
[package.dev-dependencies]
dev = [
{ name = "httpx" },
- { name = "jsonschema" },
+ { name = "jsonschema", extra = ["format"] },
{ name = "mypy" },
{ name = "pytest" },
{ name = "pytest-cov" },
@@ -533,7 +588,7 @@ requires-dist = [
[package.metadata.requires-dev]
dev = [
{ name = "httpx", specifier = ">=0.27" },
- { name = "jsonschema", specifier = ">=4.21" },
+ { name = "jsonschema", extras = ["format"], specifier = ">=4.21" },
{ name = "mypy", specifier = ">=1.19" },
{ name = "pytest", specifier = ">=8.0" },
{ name = "pytest-cov", specifier = ">=5.0" },
@@ -819,6 +874,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" },
]
+[[package]]
+name = "python-dateutil"
+version = "2.9.0.post0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "six" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
+]
+
[[package]]
name = "python-dotenv"
version = "1.2.2"
@@ -888,6 +955,27 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" },
]
+[[package]]
+name = "rfc3339-validator"
+version = "0.1.4"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "six" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/28/ea/a9387748e2d111c3c2b275ba970b735e04e15cdb1eb30693b6b5708c4dbd/rfc3339_validator-0.1.4.tar.gz", hash = "sha256:138a2abdf93304ad60530167e51d2dfb9549521a836871b88d7f4695d0022f6b", size = 5513, upload-time = "2021-05-12T16:37:54.178Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/7b/44/4e421b96b67b2daff264473f7465db72fbdf36a07e05494f50300cc7b0c6/rfc3339_validator-0.1.4-py2.py3-none-any.whl", hash = "sha256:24f6ec1eda14ef823da9e36ec7113124b39c04d50a4d3d3a3c2859577e7791fa", size = 3490, upload-time = "2021-05-12T16:37:52.536Z" },
+]
+
+[[package]]
+name = "rfc3987"
+version = "1.3.8"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/14/bb/f1395c4b62f251a1cb503ff884500ebd248eed593f41b469f89caa3547bd/rfc3987-1.3.8.tar.gz", hash = "sha256:d3c4d257a560d544e9826b38bc81db676890c79ab9d7ac92b39c7a253d5ca733", size = 20700, upload-time = "2018-07-29T17:23:47.954Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/65/d4/f7407c3d15d5ac779c3dd34fbbc6ea2090f77bd7dd12f207ccf881551208/rfc3987-1.3.8-py2.py3-none-any.whl", hash = "sha256:10702b1e51e5658843460b189b185c0366d2cf4cff716f13111b0ea9fd2dce53", size = 13377, upload-time = "2018-07-29T17:23:45.313Z" },
+]
+
[[package]]
name = "rpds-py"
version = "2026.5.1"
@@ -1023,6 +1111,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/15/19/016553f86f207450aebebc2b2b5088d086b901cc8186c02ac4284db3bd88/ruff-0.15.16-py3-none-win_arm64.whl", hash = "sha256:8cd61783afb39638a7133ef0d2dfb1e91277593962f81b5a8423eb0b888a6121", size = 11134555, upload-time = "2026-06-04T16:33:00.136Z" },
]
+[[package]]
+name = "six"
+version = "1.17.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
+]
+
[[package]]
name = "sqlalchemy"
version = "2.0.50"
@@ -1107,6 +1204,24 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
]
+[[package]]
+name = "tzdata"
+version = "2026.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/ba/19/1b9b0e29f30c6d35cb345486df41110984ea67ae69dddbc0e8a100999493/tzdata-2026.2.tar.gz", hash = "sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10", size = 198254, upload-time = "2026-04-24T15:22:08.651Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7", size = 349321, upload-time = "2026-04-24T15:22:05.876Z" },
+]
+
+[[package]]
+name = "uri-template"
+version = "1.3.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/31/c7/0336f2bd0bcbada6ccef7aaa25e443c118a704f828a0620c6fa0207c1b64/uri-template-1.3.0.tar.gz", hash = "sha256:0e00f8eb65e18c7de20d595a14336e9f337ead580c70934141624b6d1ffdacc7", size = 21678, upload-time = "2023-06-21T01:49:05.374Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e7/00/3fca040d7cf8a32776d3d81a00c8ee7457e00f80c649f1e4a863c8321ae9/uri_template-1.3.0-py3-none-any.whl", hash = "sha256:a44a133ea12d44a0c0f06d7d42a52d71282e77e2f937d8abd5655b8d56fc1363", size = 11140, upload-time = "2023-06-21T01:49:03.467Z" },
+]
+
[[package]]
name = "uvicorn"
version = "0.48.0"
@@ -1249,6 +1364,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/46/4b/95ab2f256bb4af3cb2eb23b9317bda984ee6e0f11733a5c004a6c95b06e3/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:922c0e019fe68b3ae392965a766b02a71ba1168c932cebc3733cd52c5fe5b377", size = 657684, upload-time = "2026-05-18T04:31:32.027Z" },
]
+[[package]]
+name = "webcolors"
+version = "25.10.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/1d/7a/eb316761ec35664ea5174709a68bbd3389de60d4a1ebab8808bfc264ed67/webcolors-25.10.0.tar.gz", hash = "sha256:62abae86504f66d0f6364c2a8520de4a0c47b80c03fc3a5f1815fedbef7c19bf", size = 53491, upload-time = "2025-10-31T07:51:03.977Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e2/cc/e097523dd85c9cf5d354f78310927f1656c422bd7b2613b2db3e3f9a0f2c/webcolors-25.10.0-py3-none-any.whl", hash = "sha256:032c727334856fc0b968f63daa252a1ac93d33db2f5267756623c210e57a4f1d", size = 14905, upload-time = "2025-10-31T07:51:01.778Z" },
+]
+
[[package]]
name = "websockets"
version = "16.0"