diff --git a/.gitignore b/.gitignore index 6c96573..4fb96c9 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,4 @@ findings-rust.jsonl .mcp.json loomweave.yaml weft.toml +.wardline diff --git a/CHANGELOG.md b/CHANGELOG.md index 497c51e..106651a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,124 @@ The cross-member MCP seam contracts are versioned independently as `warpline..v1` and frozen at the federation clean-break launch; a `v2` is a new contract URI, never a mutation of `v1`. +## [Unreleased] + +### Added +- **`warpline_project_status_get` / `project_status` — read-only store-binding + probe (`warpline.project_status.v1`).** A new MCP tool that reports whether THIS + build can read and *serve* the snapshot store for a given `repo` + (`data.binding_ok`), reading `schema_version` **from inside** the store — never + mere directory existence — so a stale-but-running warpline that cannot read its + `.weft/warpline` store at a compatible schema is caught (the federation + attachment signal Lacuna's `make verify` harness asserts on). It is the first + **genuinely** read-only tool: `writes_local_state: false`, `mutates_paths: []`, + and it creates/migrates **no snapshot state** — an absent store reports + `store_status: store_absent` with a `capture_snapshot` next-action (no DB is + created), a corrupt store `store_unreadable`, and a store written by a newer + build `schema_ahead` (all three: `binding_ok: false`, `schema_version: null`); + a present store's `warpline.db` is left byte-for-byte unchanged. Reads the store + strictly read-only (`mode=ro`, no create-on-missing; opening a present WAL store + may spawn gitignored `-wal`/`-shm` coordination sidecars, which are not snapshot + state). The frozen + six-tool federation-contract inventory (`mcp-tool-inventory.json`) is unchanged + — this is an additive health probe, not a frozen data contract. +- **legis governance read consumer (`governance_read.v1`).** `reverify + --include_federation` now lights up the previously-inert `legis` member with a + real `LegisGovernanceClient` over the `legis governance-read ` verb + (output is always JSON; matched to legis's shipped CLI, no `--json` flag), + consuming legis's authoritative `governance_read.v1` (verified clearances only: + operator override / cleared sign-off). Mirrored BYTE-FOR-BYTE as the source of + truth at `contracts/governance_read.v1.schema.json` (legis OWNS it; warpline + echoes advisorily and NEVER gates — `GV-LG-1`, no `governance_verdict` in + output). The mirror tracks legis's hardened discriminated union (`unavailable` ⇒ + non-empty reasons + empty `records`; `checked` ⇒ no `unavailable` key) — a + backward-compatible tightening, pinned by consumer-side rejection tests so an + `unavailable` answer can never masquerade as a clean empty. + The clearance `content_hash` is echoed verbatim, NOT re-derived against the current + body (governance is an echo, not a warpline-asserted verdict — contrast the + attest-2 path). Honesty: an empty read is `governance: absent` = "no verified + clearance," which deliberately conflates *ungoverned*, *unknown-SEI*, and an + entity **actively BLOCKED awaiting sign-off** — so `absent` is never + "ungoverned" (disclosed in the schema + the federation reference docs). Wiring + is **capability-gated**: the client is wired only when the installed legis + advertises `governance-read`; until then the member is honestly `disabled` + (capability absent), not a forced `unreachable`, and lights up automatically + once legis ships the verb. The `governance_read.v1` schema vectors are the + contract's canonical samples, not a live capture (the read surface is unshipped + at time of writing). +- **Risk-as-verification: wardline-attest-2 consumer (Rung 2).** Closes the + `verification_source_absent` gap D1 left open. warpline now consumes a PUSHED, + UNTRUSTED `wardline-attest-2` evidence bundle and, for a worklist whose impact + set is genuinely `complete`, reads **proven-good** iff EVERY affected entity is + attested clean at its CURRENT body — mechanical `(commit, content_hash)` + equality per SEI against the bundle's boundaries (`verdict == "clean"`, not + `dirty`, commit pins, content_hash matches loomweave's per-entity body hash). + The body hash is sourced from the SAME loomweave `entity_resolve` round trip + warpline already uses for the SEI (`resolve_content_hash_for_locator`); it is + byte-identical to the value wardline binds into the bundle (verified across + loomweave's MCP `entity_resolve` and HTTP `/api/v1/identity/sei` surfaces). The + verdict is an **echo of wardline's authority, signature NOT verified by + warpline** (`authority: "wardline"`, `signature_verified: false`) — never a + warpline-minted clean. Every honesty edge (no bundle, non-attest-2 schema, + dirty tree, null/mismatched commit, `sei_source: "unavailable"`, null sei / + content_hash, non-clean verdict, ANY unmatched affected entity) degrades to + `unavailable` with an explicit machine reason; proven-good is all-or-nothing. + Pure consumer (`_attest.worklist_risk`); layered on D1's completeness gate. + WIRED on the real surfaces: `warpline reverify --attest-bundle ` (CLI) and + the `attest_bundle` MCP arg ingest the pushed bundle; the verdict is emitted at + `data.risk_verification` on EVERY worklist (without a bundle it honestly reads + `verification_source_absent`). The per-SEI current content_hash is fetched from + the same loomweave `entity_resolve` round trip warpline already makes + (fail-soft). Documented in `contracts/reverify_worklist.v1.schema.json`. +- **Impact-completeness self-assessment (federation D1).** The reverify worklist + now carries an additive `data.impact_completeness` object — + `{status: complete|partial|unknown, as_of, graph_fresh, graph_ref, depth_capped, + unresolved_count, reasons[]}` — warpline's honest verdict on whether the impact + set is exhaustive for the change. One object declares BOTH axes: the staleness + axis (`as_of` producer timestamp + `graph_fresh` + `graph_ref`) and the + completeness axis (`status` + `depth_capped` + `unresolved_count`). + `status="complete"` is emitted ONLY when the graph is positively fresh (FULL, + `commits_behind==0`), the blast traversal hit no depth cap, and zero changed + entities were unresolved; any gap → `partial`; no graph at all → `unknown`. + Never `complete` on a guess. A new `depth_capped` signal in the blast traversal + honestly reports when a depth-bounded scope left reachable impact unexplored. + This is the field downstream consumers (wardline mirrors it verbatim into + `producer_completeness`) rely on to NOT treat a narrowed scope as authoritative. + Published as a drift-checkable contract artifact at + `contracts/reverify_worklist.v1.schema.json` (JSON Schema, draft 2020-12), which + validates real worklist output. Consumer side (risk-as-verification): an absent + or non-`complete` assessment degrades warpline's own risk path to + `risk=unavailable` with an explicit reason (`completeness_not_declared` / + `completeness_partial`) — never `clean`. Additive on `.v1`: the FROZEN raw + snapshot-completeness `data.completeness` STRING enum is unchanged (raw signal + vs. derived assessment); no `v2` bump. +- **Verification freshness (Rung 2, Track B).** The reverify worklist now carries + an advisory per-item `verification` block (`fresh` / `stale` / `unverified` / + `unavailable`) with a trust-decay signal, plus a `verification_summary` rollup — + answering "what changed since it was last proven good." Sourced from warpline's + own gate result via a new mutating verb `verify-record` (CLI) / + `warpline_verification_record` (MCP), the 2nd local-only mutating tool. Advisory + and enrich-only: it annotates and re-sorts (stale-of-trust first) but NEVER + filters an item, and NEVER gates. Sibling-sourced verification (wardline/ + filigree/legis) remains honest-absent RESERVED. New schema v4 + (`verification_events`); golden vector `GV-VF-1`. The frozen `warpline..v1` + envelope and the closed 6-key enrichment vocab are untouched (verification rides + the reverify-item schema, not the enrichment vocab). + +### Fixed +- **Weft-reason honesty invariant now survives `python -O`.** `listing.reason()` + enforced its carrier rule (class-membership, and "every non-clean carrier MUST + carry both cause and fix") with bare `assert`s, which `-O` strips — so under + `-O` a hollow `{reason_class: "disabled"}` triple with no cause/fix could ship, + the exact unexplained-absence the honesty doctrine forbids. Promoted both checks + to raised `ValueError`, and hardened `build_envelope` to reject a non-clean + `enrichment_reasons` triple missing cause/fix (closing the parallel + hand-built-via-kwarg path, which bypassed `reason()` even without `-O`). + `sei_reason()` is now non-Optional — it raises on an out-of-vocab state, which + removed four `-O`-strippable narrowing asserts at its call sites. Internal + hardening only; the frozen `warpline..v1` envelope and the closed + enrichment vocab are unchanged. + ## [1.2.0] - 2026-06-24 Minor release: spine hardening. Snapshot capture is now correct-by-construction and diff --git a/README.md b/README.md index 2fe9397..82d0b6c 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ never decides whether a change is allowed.** This is deconfliction tooling, not security. A warpline answer is an enhancement you can act on or ignore — never a verdict you must clear. It **consumes** Loomweave SEI (it never mints identity) and **feeds** advisory change-impact facts to governance-style surfaces such as -Legis/Charter, which run their own policy; warpline supplies the facts and never +Legis/Plainweave, which run their own policy; warpline supplies the facts and never makes the call. The product front door lives at 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/contracts/reverify_worklist.v1.schema.json b/contracts/reverify_worklist.v1.schema.json new file mode 100644 index 0000000..3802ab2 --- /dev/null +++ b/contracts/reverify_worklist.v1.schema.json @@ -0,0 +1,157 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://weft.dev/contracts/warpline/reverify_worklist.v1.schema.json", + "title": "warpline.reverify_worklist.v1", + "description": "Published contract for the warpline reverify worklist envelope. This is the drift-checkable artifact the Weft federation gate (wardline) validates its vendored fixtures against. warpline emits this payload; wardline consumes it as a PUSHED, UNTRUSTED producer claim and never gates on it. The contract is additive within v1: any incompatible change MUST bump to .v2. Fields beyond those constrained here are permitted (additionalProperties: true) so warpline may enrich the envelope without breaking consumers; the federation-relevant fields (entity.{locator,sei}, generated_at, impact_completeness) are pinned.", + "type": "object", + "required": [ + "schema", + "ok", + "query", + "data", + "warnings", + "next_actions", + "enrichment", + "enrichment_reasons", + "meta" + ], + "additionalProperties": true, + "properties": { + "schema": { "const": "warpline.reverify_worklist.v1" }, + "ok": { "type": "boolean" }, + "query": { "type": "object" }, + "warnings": { "type": "array", "items": { "type": "string" } }, + "next_actions": { "type": "object" }, + "enrichment": { "type": "object" }, + "enrichment_reasons": { "type": "object" }, + "meta": { + "type": "object", + "required": ["local_only", "peer_side_effects"], + "additionalProperties": true, + "properties": { + "local_only": { "const": true }, + "peer_side_effects": { "type": "array", "maxItems": 0 } + } + }, + "data": { "$ref": "#/$defs/data" } + }, + "$defs": { + "data": { + "type": "object", + "additionalProperties": true, + "required": [ + "completeness", + "impact_completeness", + "staleness", + "items", + "resolved", + "unresolved" + ], + "properties": { + "completeness": { + "description": "Raw snapshot edge-completeness enum (a STRING). This is the FROZEN v1 field; the derived self-assessment lives in impact_completeness.", + "enum": ["FULL", "DELTA", "NO_SNAPSHOT", "SKIPPED"] + }, + "staleness": { + "type": "object", + "additionalProperties": true, + "required": ["snapshot_commit", "commits_behind"], + "properties": { + "snapshot_commit": { "type": ["string", "null"] }, + "commits_behind": { "type": ["integer", "null"] } + } + }, + "impact_completeness": { "$ref": "#/$defs/impact_completeness" }, + "risk_verification": { "$ref": "#/$defs/risk_verification" }, + "items": { "type": "array", "items": { "$ref": "#/$defs/item" } }, + "resolved": { "type": "array" }, + "unresolved": { "type": "array" } + } + }, + "impact_completeness": { + "description": "warpline's honest self-assessment of the impact analysis's completeness + staleness, in ONE object. wardline mirrors it VERBATIM into its producer_completeness scope-honesty field. Staleness axis: as_of (producer timestamp) + graph_fresh + graph_ref. Completeness axis: status + depth_capped + unresolved_count. status='complete' ONLY when the impact set is genuinely exhaustive (fresh FULL graph, no depth cap, zero unresolved); 'partial' when any coverage gap is known; 'unknown' when coverage cannot be assessed (no graph). Never 'complete' on a guess.", + "type": "object", + "additionalProperties": false, + "required": [ + "status", + "as_of", + "graph_fresh", + "graph_ref", + "depth_capped", + "unresolved_count", + "reasons" + ], + "properties": { + "status": { "enum": ["complete", "partial", "unknown"] }, + "as_of": { + "description": "Producer generation timestamp (RFC3339 / ISO 8601, timezone-qualified) — the staleness axis. wardline echoes this as an unverified staleness proxy. The pattern asserts the shape portably; format is annotation-only under draft 2020-12.", + "type": "string", + "format": "date-time", + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d+)?(Z|[+-]\\d{2}:\\d{2})$" + }, + "graph_fresh": { + "description": "Was the loomweave edge graph positively current vs the working tree (commits_behind == 0)?", + "type": "boolean" + }, + "graph_ref": { + "description": "The snapshot commit the impact graph was captured at, or null when no graph exists.", + "type": ["string", "null"] + }, + "depth_capped": { + "description": "Was the blast-radius traversal truncated at the depth/fan-out horizon?", + "type": "boolean" + }, + "unresolved_count": { + "description": "Count of changed entities that could NOT be mapped to graph nodes.", + "type": "integer", + "minimum": 0 + }, + "reasons": { + "description": "Closed machine-code vocabulary explaining a non-complete status.", + "type": "array", + "items": { + "enum": [ + "graph_stale", + "graph_freshness_unknown", + "partial_snapshot", + "depth_capped", + "unresolved_entities", + "no_snapshot", + "snapshot_skipped" + ] + } + } + } + }, + "risk_verification": { + "description": "warpline's risk-as-verification posture for this change (Rung 2). Always emitted. risk='proven' (reason_code 'attested_clean') ONLY when the worklist is impact-complete AND every affected entity is attested clean at its current body by a supplied, PUSHED wardline-attest-2 bundle (mechanical (commit, content_hash) equality; the HMAC signature is NOT verified by warpline, so signature_verified is false and authority is 'wardline' — an echo, never a warpline-minted clean). Otherwise risk='unavailable' with a machine reason_code (completeness_not_declared, completeness_partial, verification_source_absent, attestation_schema_unknown, attestation_dirty, attestation_no_commit, attestation_commit_mismatch, attestation_unkeyed, attestation_incomplete).", + "type": "object", + "additionalProperties": true, + "required": ["risk", "reason_code", "reason"], + "properties": { + "risk": { "enum": ["proven", "unavailable"] }, + "reason_code": { "type": "string" }, + "reason": { "type": "object" }, + "authority": { "type": "string" }, + "source": { "type": "string" }, + "signature_verified": { "type": "boolean" } + } + }, + "item": { + "type": "object", + "additionalProperties": true, + "required": ["entity"], + "properties": { + "entity": { + "type": "object", + "additionalProperties": true, + "required": ["locator", "sei"], + "properties": { + "locator": { "type": ["string", "null"] }, + "sei": { "type": ["string", "null"] } + } + } + } + } + } +} diff --git a/docs/arch-analysis-2026-06-28-0728/00-coordination.md b/docs/arch-analysis-2026-06-28-0728/00-coordination.md new file mode 100644 index 0000000..7bca923 --- /dev/null +++ b/docs/arch-analysis-2026-06-28-0728/00-coordination.md @@ -0,0 +1,78 @@ +# 00 — Coordination Plan + +## Analysis Configuration + +- **Subject**: `warpline` — Weft federation temporal / change-impact authority (v1.2.0) +- **Scope**: `src/warpline/` only — 30 `.py` files, ~10,411 LOC, flat package + `skills/` payload +- **Deliverables**: **Option C (Architect-Ready)** — discovery, catalog, diagrams, final report, quality assessment, architect handover +- **Strategy**: **HYBRID** (see Orchestration Decision) +- **Time constraint**: none stated +- **Complexity estimate**: **Medium** — small file count, but high per-module conceptual density (contract-first, honesty-invariant design); one god-module (`store.py`) and one god-function (`reverify_worklist`) + +## Subject Snapshot (evidence) + +| Signal | Value | Source | +| --- | --- | --- | +| Language / runtime | Python ≥ 3.12, `mypy --strict`, ruff (E,F,I,UP,B) | `pyproject.toml` | +| Runtime dependencies | **0** (stdlib only) | `pyproject.toml:24` | +| Entry points | `warpline.cli:main`, `warpline.mcp:main` | `pyproject.toml:34-36` | +| LOC (src) | 10,411 across 30 files | `find/wc` | +| Largest modules | `store.py` 1863, `commands.py` 1486, `mcp.py` 777, `cli.py` 601 | `wc -l` | +| Loomweave index | **fresh** at HEAD `def6d43` (112 files, 1076 entities, 2667 edges) | `index_diff_get` (authoritative oracle) | +| Circular imports | **0** | `module_circular_import_list` | +| Foundation hub | `warpline.store` — fan_in **38**, fan_out **0** (internal) | `entity_coupling_hotspot_list` | +| Orchestration hub | `commands.reverify_worklist` — fan_out **34**, 276 LOC | `entity_coupling_hotspot_list` | +| Prior name | `heddle` (renamed to warpline; loomweave carries `heddle.*` tombstones) | `index_diff_get.missing_files` | + +## Orchestration Decision + +The command's PARALLEL trigger requires **≥5 subsystems AND ~20K+ LOC AND loosely coupled**. This +codebase meets only one of three: it is **10.4k LOC** (half the threshold) and a **tightly-coupled +flat package** (`store.py` is a god-module everything imports; `cli`/`mcp` are thin surfaces over +`commands.py`). The command's SEQUENTIAL trigger ("tight interdependencies") fits better. + +Decisive factor: the loomweave graph (2667 edges + coupling hotspots) **already holds** the +inbound/outbound dependency backbone — the most valuable and most error-prone field in every catalog +entry. Fanning out blind explorers would reconstruct that *worse* and cost more. So: + +- **Catalog (02), diagrams (03), discovery (01), report (04), handover (06)** — self-authored from + the loomweave graph + targeted full reads of the behavior-critical modules (`store.py`, + `commands.py`) and symbol-level reads of the rest. +- **Quality (05)** — independent critique genuinely beats self-assessment, so dispatch the + `architecture-critic` and `debt-cataloger` subagents, fed the coupling hotspots + loomweave + findings. Synthesize their output. +- **Validation gates (mandatory)** — `analysis-validator` after the catalog and after the final set. + +## Execution Log + +- `2026-06-28 07:28` Created workspace `docs/arch-analysis-2026-06-28-0728/`. +- `2026-06-28 07:28` Orientation: confirmed Python (not Rust), flat `src/warpline/` package, zero deps. +- `2026-06-28 07:29` Resolved freshness conflict — `index_diff_get` authoritative = **fresh** at HEAD; no re-analyze. +- `2026-06-28 07:30` Advisor checkpoint #1: right-sized orchestration to HYBRID; locked decomposition. +- `2026-06-28 07:32` Verified guessed modules (cop/listing/productization/_attest/verification); decomposition = 8 layered subsystems. +- `2026-06-28 07:34` Deep-read `commands.py` (full) + `store.py` (schema, migrations, binding, reresolve) + `errors.py`; pulled coupling hotspots, findings, store API inventory. +- `2026-06-28 07:36` Wrote 00 / 01 / 02 / 03 / 04. +- `2026-06-28 07:36` Dispatched 3 parallel background agents: `analysis-validator` (catalog gate), + `architecture-critic`, `debt-cataloger`. Independently cross-checked TODO/FIXME (zero) + the + `test_attest` secret findings (false positives — content hashes, not credentials). +- `2026-06-28 07:55` `debt-cataloger` → `temp/debt-catalog.md` (12 items, 1 High = D2 FK-less tables; + caught the inert `# noqa: BLE001`). +- `2026-06-28 07:59` `analysis-validator` → `temp/validation-catalog.md`: **PASS-WITH-FIXES**. Caught an + S2↔S3 back-edge (`store`→`coupling`, lazy) + 4 missed cross-edges (function-body imports loomweave's + graph omits) + minor count fixes. **All fixes applied** to 02 / 03 / 04. +- `2026-06-28 08:01` `architecture-critic` → `05-quality-assessment.md`: **4/5**, F1-F11; added F5 + (silent-correctness positional invariant) + F4 (throttle gap) beyond the debt pass. Reconciled the + "single-direction" claim in 05 against the corrected catalog. +- `2026-06-28 08:0x` Wrote `06-architect-handover.md` (unified F↔D backlog U1-U17, 3-wave sequence). +- `2026-06-28 08:0x` Final validation gate: `analysis-validator` over the full set for cross-document + consistency → `temp/validation-final.md`. + +## Limitations + +- Scope is `src/` only: `tests/`, `contracts/`, `site/`, `solution-architecture/`, `spike/`, + `scripts/` are referenced for context (testing posture, frozen schemas) but **not catalogued**. +- `store.py` was deep-read through line 1342 + full method inventory of the remainder (lines + 1343-1864 cover `churn_for_entity`, `update_co_change_pairs`, `co_change_partners`, + snapshot-write methods, `capture_snapshot_atomic`); their behavior is inferred from signatures + + docstrings, not line-by-line for the second half. Confidence: High (the read half establishes the + module's patterns conclusively). diff --git a/docs/arch-analysis-2026-06-28-0728/01-discovery-findings.md b/docs/arch-analysis-2026-06-28-0728/01-discovery-findings.md new file mode 100644 index 0000000..31385c5 --- /dev/null +++ b/docs/arch-analysis-2026-06-28-0728/01-discovery-findings.md @@ -0,0 +1,144 @@ +# 01 — Discovery Findings (Holistic Assessment) + +> Scope: `src/warpline/` at HEAD `def6d43`. Evidence-based; confidence noted per claim. + +## 1. What warpline is + +warpline is the **temporal / change-impact authority** of the **Weft federation** — a family of +local-first developer tools (loomweave, filigree, wardline, legis/plainweave, warpline). It owns the +one fact no sibling stores: **per-entity change history across git history, keyed on SEI**, plus the +downstream-propagation query over it. It answers the question an agent asks before claiming a change +is done: + +> *Given this diff: which entities changed, by whom, when — what is downstream-affected over the +> call graph, and what must I re-verify?* + +Two design commitments shape every line of the codebase (confidence: **High** — stated in README and +enforced structurally throughout `commands.py` / `envelope.py`): + +1. **Advisory, never gating** ("deconfliction-first"). warpline enriches; it never enforces policy + or decides whether a change is allowed. It *feeds* facts to governance siblings (legis/plainweave) + that run their own policy. +2. **Enrich-only, local-first.** It boots, ingests, and answers with **no sibling installed**. All + state lives under `.weft/warpline/` (git-ignored); the only mutating tools write there and never + touch a sibling repo. Every response carries `meta.local_only: true`, `peer_side_effects: []`. + +## 2. Technology stack + +| Concern | Choice | Evidence | +| --- | --- | --- | +| Language | Python ≥ 3.12 (uses 3.12+ generics, `X | Y` unions, `match`-era stdlib) | `pyproject.toml` | +| Runtime deps | **None** — pure stdlib (`sqlite3`, `subprocess`, `urllib`, `argparse`, `json`, `hashlib`, `selectors`) | `pyproject.toml:24` | +| Persistence | Embedded **SQLite** (WAL, `RETURNING` → floor 3.35.0), forward-only migrations | `store.py:14-17,35-107` | +| Interfaces | **CLI** (`argparse`) + **MCP server** (hand-rolled JSON-RPC over stdio) | `cli.py`, `mcp.py` | +| Identity | Consumes **loomweave SEI** (`loomweave:eid:…`); never mints/parses it | `loomweave.py`, README | +| Packaging | hatchling; ships `skills/` payload as wheel artifact; uv-tool install | `pyproject.toml:42-61` | +| Quality gates | `mypy --strict`, `ruff`, `pytest` + 14 golden contract vectors | `pyproject.toml:63-77`, README | + +The **zero-runtime-dependency** posture is the single most distinctive stack fact: warpline is +installable as a self-contained uv tool / pip package with no transitive supply chain, consistent +with the rest of the federation. + +## 3. Directory & module organization + +`src/` is a **flat package** — `src/warpline/*.py` (no nested sub-packages) plus a non-code +`skills/warpline-workflow/` payload (the injectable agent skill). The 30 modules organize by +**architectural layer**, not by directory. Module sizes (LOC): + +``` +store.py 1863 · commands.py 1486 · mcp.py 777 · cli.py 601 · dogfood.py 575 · install_support.py 513 +listing.py 437 · loomweave.py 433 · cop.py 428 · federation.py 418 · git.py 324 · snapshot.py 257 +_attest.py 250 · mcp_smoke.py 245 · _completeness.py 225 · siblings.py 191 · verification.py 180 +_blast.py 159 · _enrichment.py 146 · errors.py 139 · productization.py 123 · reverify.py 117 +propagation.py 105 · envelope.py 105 · reresolve.py 95 · refs.py 78 · coupling.py 77 +install.py 27 · locators.py 26 · __init__.py 11 +``` + +## 4. Entry points & runtime flows + +Two real entry points (loomweave `entity_entry_point_list`, filtered of `heddle.*` tombstones): + +- **`warpline.cli:main`** (`cli.py:381`, fan_out 31) — argparse dispatcher for `install`, `doctor`, + `backfill`, `ingest-commit`, `changed`, `timeline`, `churn`, `capture-snapshot`, `reverify`, + `verify-record`, `project-status`, `reresolve`, `co-change`, `cop`, `mcp-smoke`, `dogfood-eval`. +- **`warpline.mcp:main`** (`mcp.py:758`) — JSON-RPC stdio loop exposing **8 tools** (6 frozen + federation contracts + 2 additive: `verify_record`, `project_status`), each with endorsed name + + short shim returning identical schema+data. + +**The core loop** (both surfaces converge on `commands.py`): +`backfill`/`ingest-commit` (git → store) → `changed` → `capture-snapshot` (loomweave edges → store) +→ `impact_radius` / `reverify` (store + edges → worklist). A post-commit git hook keeps the store +fresh; a SessionStart hook emits `commands.session_context`. + +## 5. Subsystem identification (8 layered subsystems) + +Derived from imports, the loomweave edge graph, and module docstrings. Confidence **High** +(decomposition cross-checked against `commands.py` import block and coupling hotspots). + +| # | Subsystem | Modules | Role | +| --- | --- | --- | --- | +| **S1** | Contract & Envelope Foundation | `errors`, `envelope`, `_enrichment`, `listing`, `refs`, `locators` | Frozen output envelope, closed error/enrichment/reason vocabularies, input-ref parsing, list ergonomics (filter/sort/page/overflow) | +| **S2** | Temporal Store (persistence) | `store`, `snapshot` | SQLite schema + forward-only migrations + `WarplineStore` data-access (40 methods); edge-snapshot capture orchestration | +| **S3** | Domain Compute (pure analytics) | `_blast`, `propagation`, `_completeness`, `coupling`, `verification`, `_attest`, `reverify` | Pure functions: blast-radius traversal, impact-completeness, co-change coupling, verification-freshness, attest risk, worklist render | +| **S4** | Command Orchestration | `commands`, `cop` | The 8 tool bodies; wires store + compute + seams + envelope; COP posture composition | +| **S5** | Resolution & Ingestion Seams | `loomweave`, `git`, `reresolve` | loomweave SEI/edge resolution (subprocess MCP client); git history ingestion; self-healing SEI re-resolution | +| **S6** | Federation Enrichment Seams | `federation`, `siblings` | Cross-member consults: filigree work-state, wardline risk dossier, legis governance read | +| **S7** | Interface Surfaces | `cli`, `mcp`, `mcp_smoke` | argparse CLI; JSON-RPC MCP server; live stdio smoke test | +| **S8** | Lifecycle & Productization | `install`, `install_support`, `productization`, `dogfood` | Federation member install/doctor; release-readiness decision; dogfood evaluation harness | + +## 6. Architecture style (first read) + +**Layered + ports-and-adapters (hexagonal).** A pure domain core (S2 store + S3 compute) sits behind +the S4 command layer; external systems are reached only through **`typing.Protocol` ports** +(`ToolClient`, `NeighborhoodClient`, `WorkClient`, `RiskClient`, `LegisClient`, `RenameFeed`) whose +concrete adapters live in S5/S6. Two thin surfaces (S7) translate transport (argparse / JSON-RPC) to +the same `commands.py` functions — confirmed by `cli.py` and `mcp.py` both importing `commands` and +delegating. This is a deliberate, consistently-applied pattern, not incidental. + +## 7. Cross-cutting design invariants (the "house style") + +These recur across subsystems and are the spine of the system's correctness story (confidence +**High** — observed directly in code): + +- **Honesty invariant.** Absence is explicit: a **closed enrichment vocabulary** + (`present | absent | unavailable`, plus `stale|partial|skipped` for edges; `envelope.py:12-20`). + `absent` (peer present, no fact) is never conflated with `unavailable` (peer unreachable), and + neither is ever a transport error or an implied "clean/allowed" state. +- **weft-reason (G1).** A degraded/empty result carries `{reason_class, cause, fix}` over **11 closed + reason classes** so an empty is never byte-indistinguishable from an earned true-negative + (`listing.py:14-33`). +- **Frozen contracts.** 8 `warpline..v1` schema URIs + `warpline.error.v1` (11 closed + error codes × 3 retryability values, `errors.py:8-23`). A `v2` is a **new URI**, never a mutation + of `v1`. The authoritative interface-lock is **hub-owned**; warpline implements *to* it. +- **SEI-orthogonality.** warpline keys on loomweave SEI but mints/parses no identity; the SEI is an + opaque external string joined at read time, never stored in derived tables (`store.py:177-179`). +- **Additive, forward-only schema.** Base `SCHEMA` frozen after Rung 1a; all change via ordered + `MIGRATIONS` (v2/v3/v4) under `BEGIN IMMEDIATE` atomicity with a **schema-presence floor** that + re-runs migrations when a version marker isn't backed by on-disk objects (`store.py:469-630`). +- **Fail-soft advisory side effects.** The pure traversal (`blast_radius`) never does I/O; side + effects (lazy snapshot capture, attest hashing) live in tool bodies wrapped in `except Exception` + so an unreachable loomweave degrades to `NO_SNAPSHOT`, never an error or a fake-clean graph + (`commands.py:529-560, 674-675`). + +## 8. Evolution narrative ("Rungs") + +The code is annotated with an incremental build ladder (confidence **High** — pervasive in +docstrings + CHANGELOG): + +- **Rung 1a** froze the base `SCHEMA`; **1b** added working-context anchor columns (v2); **1c** added + self-healing SEI re-resolution (`reresolve`); **1d** added always-on lazy edge-snapshot capture. +- **Rung 2** added Track **A** co-change coupling (v3), Track **B** verification freshness (v4), + Track **C** per-item risk/governance enrichment, Track **D** the COP posture frame. + +Decisions are PDR-governed (e.g. PDR-0023 "no advertise-and-ignore dead input"; GV-LG-1 "governance +is an echo, never a warpline verdict"). This is a **disciplined, contract-first, incrementally-built +system**, not an organically-grown one. + +## 9. Open questions carried into the catalog + +- Does `store.py`'s size (1863 LOC, one class with 40 methods) cross from "cohesive" into + "god-module"? → Quality assessment. +- Is `reverify_worklist` (276 LOC, fan_out 34, orchestrating ≥8 concerns) the system's complexity + hotspot? → Quality assessment. +- Are the 3 `test_attest.py` HighEntropyHex findings synthetic fixtures (waivable) or real? → Quality + assessment (`.env` is confirmed git-ignored). diff --git a/docs/arch-analysis-2026-06-28-0728/02-subsystem-catalog.md b/docs/arch-analysis-2026-06-28-0728/02-subsystem-catalog.md new file mode 100644 index 0000000..15303e2 --- /dev/null +++ b/docs/arch-analysis-2026-06-28-0728/02-subsystem-catalog.md @@ -0,0 +1,368 @@ +# 02 — Subsystem Catalog + +> 8 layered subsystems over `src/warpline/` (30 modules). Dependency directions derived from import +> blocks + the loomweave edge graph (2667 edges). `heddle.*` tombstone edges excluded. + +Layer order (foundation → surface): roughly **{S1, S2} → S3 → S4 → {S5, S6} → S7**, with **S8** as an +out-of-band lifecycle/eval band. **S1 and S2 are *parallel* foundations** (neither imports the other: +`store.py` has no module-level internal imports; S1 imports nothing from S2). There are **no +module-level circular imports** (`module_circular_import_list` = 0) — but at *subsystem* granularity +there is one **S2↔S3 back-edge** (`store` → `coupling` via deliberate function-body imports, +`store.py:1468-1469,1554`; `propagation`/`_blast` → `store`). The back-edge is real coupling; it stays +acyclic at module level only because `coupling.py` is a pure leaf and `store`'s edge is lazy. See the +dependency summary at the end. + +> **Methodology caveat (from catalog validation):** loomweave's edge graph does not capture +> *function-body* (lazy) imports. Two edges — `store`→`coupling` and `cli`→`coupling` — are lazy and +> were added after `analysis-validator` cross-checked the raw `import` statements. All 30 modules +> including `__init__.py` (which exports only `__version__`) are accounted for. + +--- + +## S1 — Contract & Envelope Foundation + +**Location:** `src/warpline/{errors,envelope,_enrichment,listing,refs,locators}.py` (~931 LOC) + +**Responsibility:** Define and enforce the frozen wire contract — the success envelope, the closed +error/enrichment/reason vocabularies, input-ref parsing, and the list-ergonomics pipeline — so every +tool response is shaped and degraded identically. + +**Key Components:** +- `errors.py` — `WarplineError` base + **11 subclasses** pinning the **closed `warpline.error.v1` + vocabulary** (11 `error_code`s, 3 `retryability` values; asserted in `to_error_data`). +- `envelope.py` — `build_envelope`, `enrichment_state`, `local_only_meta`; the **closed + `ENRICHMENT_VOCAB`** (`envelope.py:12-20`) and the `meta.local_only`/`peer_side_effects: []` stamp. +- `listing.py` — the `filter → sort → overflow → page → group_by` pipeline + **`reason()`** over the + 11 weft-reason classes (G1 contract); cursor encode/decode; overflow-to-file spill. +- `refs.py` — `parse_entity_ref` / `parse_changed_refs` over the 6 frozen ref kinds + (`auto|locator|sei|path|qualname|warpline_entity_key_id`); `entity_view` (the frozen `{locator, sei}` + view). +- `_enrichment.py` — pure staleness/completeness → enrichment-string mapping + warning composition. +- `locators.py` — `python_entity_locators` (path → candidate Python entity locators). + +**Dependencies:** +- Inbound: most subsystems (S3, S4, S5, S6, S7) — but **not** `store.py` (S2) and **not** + `install_support` (S8), which import nothing from S1. So S1 is a foundation *alongside* S2, not + beneath it. `listing.reason` has fan_in 16. +- Outbound: intra-S1 only (`envelope` → `_enrichment`, `listing`, `__init__`; `listing`/`refs` → + `errors`). No outbound to S2-S8. + +**Patterns Observed:** Closed vocabularies enforced by `frozenset` membership + `assert`; honesty +invariant (`absent ≠ unavailable`); contract values centralized so no surface can diverge. + +**Concerns:** `listing.py` (437 LOC) carries the most behavior in this layer and mixes pure +predicates with filesystem overflow-spill (`apply_overflow` writes a file) — a small I/O leak into an +otherwise-pure contract module. Minor. + +**Confidence:** **High** — read `errors.py` and `envelope.py` in full; `listing`/`refs` via docstrings ++ symbol maps + their call sites in `commands.py`. + +--- + +## S2 — Temporal Store (Persistence) + +**Location:** `src/warpline/{store,snapshot}.py` (~2120 LOC) + +**Responsibility:** Own all durable temporal state — the SQLite schema, forward-only migrations, and +the `WarplineStore` data-access layer — plus edge-snapshot capture from loomweave neighborhoods. + +**Key Components:** +- `store.py` (1863 LOC) — the **foundation god-module** (fan_in 38, fan_out 0 internal): + - **Schema** (frozen base `SCHEMA`): `meta`, `repos`, `entity_keys`, `commit_refs`, + `change_events`, `edge_snapshots`, `snapshot_edges`, `health_log`. + - **Migrations** v2 (anchor columns), v3 (`co_change_pairs`), v4 (`verification_events`) — + ordered, forward-only, each under `BEGIN IMMEDIATE`/`COMMIT` with `user_version` + `meta` updated + in the same txn; concurrent-open safe via `busy_timeout` + re-read under RESERVED lock. + - **`_schema_presence_floor`** — verifies a version *marker* against actual on-disk objects and + re-runs migrations from a safe floor (defends against a lying `meta.schema_version`). + - **`read_store_binding`** — the strictly-read-only `project_status` probe (`mode=ro`, creates + nothing); closed `STORE_STATUS_VOCAB` (`ok|store_absent|store_unreadable|schema_ahead`). + - **`WarplineStore`** — 40 methods: entity-key upsert/merge, change-event append/query, timeline, + churn aggregation, co-change pair upsert/rebuild, verification events, snapshot create/read, + `capture_snapshot_atomic`. Includes the intricate `reresolve_entity_key_sei` → `_merge_into_twin` + → `_repoint_{co_change_pairs,snapshot_edges}` identity-merge family. +- `snapshot.py` — `capture_edge_snapshot` / `edges_from_neighborhood`: turns a loomweave neighborhood + into `snapshot_edges` rows (the bridge from S5 into S2). + +**Dependencies:** +- Inbound: S3 (`_blast`, `propagation`), S4 (`commands`), S5 (`git`, `reresolve`), S8 (`dogfood`, + `install_support`), S7 (`cli`). +- Outbound: `store.py` → **no module-level internal imports** (this is *why* it anchors the graph) + **but one deliberate function-body edge to `coupling` (S3)** at `store.py:1469,1554` (the comment at + `:1468` documents "store → coupling is the one-way edge"). `snapshot.py` → `loomweave` (S5), + `store` (S2). + +**Patterns Observed:** Migration-runner discipline; idempotent `INSERT OR IGNORE` writes; deterministic +UTC-normalized ordering (`COALESCE(datetime(x), x)`) to avoid mixed-tz lexical-sort bugs; "never +regress a marker to NULL" merge rules; documented, explicit data-loss on merge collisions (M5). + +**Concerns:** +- **Size / cohesion.** 1863 LOC in one file holding schema DDL, the migration runner, a read-only + binding probe, *and* a 40-method data-access class. Internally cohesive but a clear split candidate + (see 05). The `_merge_into_twin` family alone is ~270 LOC of high-stakes referential-integrity + surgery on FK-less tables. +- **No DB-level FKs on the `entity_key_id` references in derived tables.** `co_change_pairs` has *no* + `FOREIGN KEY` at all; `snapshot_edges` has an FK on `snapshot_id` (→ `edge_snapshots`) but **none on + its `source_entity_key_id`/`target_entity_key_id` columns**. So referential integrity for those + entity references is maintained *manually* in the merge path (`_merge_into_twin` / `_repoint_*`) — + correct today, but fragile to future edits. (This is the debt catalog's only correctness-class item.) + +**Confidence:** **High** for lines 1-1342 (read in full) + the full method inventory; **Medium-High** +for lines 1343-1864 (signatures + docstrings, not line-by-line). + +--- + +## S3 — Domain Compute (pure analytics) + +**Location:** `src/warpline/{_blast,propagation,_completeness,coupling,verification,_attest,reverify}.py` +(~1043 LOC) + +**Responsibility:** The pure, testable analytical core — blast-radius traversal, impact-completeness +self-assessment, temporal co-change coupling, verification freshness, attest-bundle risk, and worklist +rendering. Mostly side-effect-free functions over store-read inputs. + +**Key Components:** +- `propagation.py` — `blast_radius`: BFS over `snapshot_edges` from the changed seed (the + PURE traversal, R7 — `_commits_behind` is its only subprocess, for staleness). +- `_blast.py` — `resolve_changed_inputs`, `rev_range_commits`, `enrich_blast`: prep/post around the + traversal (resolve refs → key ids; attach the frozen entity view). +- `_completeness.py` — `compute_impact_completeness` / `completeness_risk`: the federation-D1 + self-assessed `{as_of, graph_fresh, status, depth_capped, unresolved_count}` object. +- `coupling.py` — `derive_pairs_from_commit`, `classify_confidence`, `coupling_rate`: co-change + derivation (Rung 2 Track A). +- `verification.py` — `compose_verification_freshness`: git-reachability freshness + (`fresh|stale|unverified|unavailable`) via injected `covers`/`between` callbacks. +- `_attest.py` — `parse_attest_bundle`, `worklist_risk`: risk-as-verification over an untrusted + wardline-attest-2 bundle (proven-good iff every affected entity attested clean at its current body). +- `reverify.py` — `render_reverify_worklist`: assembles per-item worklist rows (depth, verification + block, work enrichment scaffold). + +**Dependencies:** +- Inbound: S4 (`commands`) is the primary caller; `reverify` is called by `commands.reverify_worklist`. +- Outbound: mostly none (pure). Exceptions: `_blast` → `git` (S5) + `store` (S2); + `propagation` → `store` (S2); `reverify` → `listing` (S1) + `siblings` (S6). + +**Patterns Observed:** **Dependency injection via callbacks/Protocols** (`verification` takes +`covers`/`between`; `reverify` takes a `WorkClient`) keeps the core pure and unit-testable without a +DB or git. Honest degradation states are first-class return values, not exceptions. + +**Concerns:** `_attest`/`verification`/`reverify` are pure but their *orchestration* (cache, ordering, +content-hash fetch) lives in `commands.reverify_worklist`, not here — so the compute layer is clean but +its assembly is concentrated in S4 (see S4 concern). + +**Confidence:** **High** — docstrings + signatures + full read of every call site in `commands.py`. + +--- + +## S4 — Command Orchestration + +**Location:** `src/warpline/{commands,cop}.py` (~1914 LOC) + +**Responsibility:** The 8 tool bodies. Each wires S2 (store) + S3 (compute) + S5/S6 (seams) + S1 +(envelope) into one frozen response. `cop.py` composes the (non-frozen) temporal change-oriented +posture frame. + +**Key Components:** +- `commands.py` (1486 LOC) — `change_list`, `entity_timeline`, `entity_churn_count`, `impact_radius`, + **`reverify_worklist`**, `capture_snapshot`, `verify_record`, `project_status`, `session_context`; + plus the **always-on lazy edge-snapshot capture** (`_lazy_capture_if_missing`) with a per-DB + throttle marker, and `_attest_content_hashes` (per-SEI loomweave round trip). +- `cop.py` — `resolve_frame` (frame kinds for the COP demo verb), `compose_temporal_cop` (Rung 2 + Track D); deliberately kept out of `cli.py`'s import path so the parser builds without pulling in + the federation consults. + +**Dependencies:** +- Inbound: S7 (`cli`, `mcp`), S8 (`dogfood`). +- Outbound: **the widest in the system** — S1 (`envelope`, `listing`, `refs`, `errors`, + `_enrichment`), S2 (`store`, `snapshot`), S3 (`_attest`, `_blast`, `_completeness`, `propagation`, + `reverify`, `verification`), S5 (`git`, `loomweave`), S6 (`federation`, `siblings`). + +**Patterns Observed:** Uniform tool-body shape (`resolve inputs → open store → compute → list +pipeline → build_envelope`); pre-page vs post-page discipline (summaries/federation/attest computed +over the FULL filtered set, pagination applied last); fail-soft advisory side effects. + +**Concerns:** +- **`reverify_worklist` is the system's complexity hotspot** (276 LOC, fan_out **34**). It + orchestrates ≥8 concerns in one function: ref resolution, lazy capture, blast, per-entity + verification-freshness (with an inline cache + two git-reachability closures), work/risk/governance + federation enrichment merge, attest content-hashing, impact-completeness, the list pipeline, and + envelope assembly. High cyclomatic complexity; hard to unit-test below the integration level. +- **Orchestration glue lives in `commands.py`, not S3** — `_lazy_capture_if_missing`, + `_attest_content_hashes`, `_merge_federation_enrichment`, `_member_scalar`, the verification cache. + These are reusable-looking helpers stranded in the command module. + +**Confidence:** **High** — `commands.py` read in full; `cop.py` via docstring + symbols + `cli.py` +call site. + +--- + +## S5 — Resolution & Ingestion Seams + +**Location:** `src/warpline/{loomweave,git,reresolve}.py` (~852 LOC) + +**Responsibility:** Bring the outside world *in* — loomweave SEI/edge resolution, git history +ingestion, and the self-healing SEI re-resolution sweep. These are the **adapters** behind the S2/S3 +ports. + +**Key Components:** +- `loomweave.py` — `LoomweaveProbe` (availability/version), `LoomweaveMcpClient` (subprocess + `loomweave serve` JSON-RPC client over stdio with `selectors`-based I/O), `resolve_sei_for_locator`, + `resolve_content_hash_for_locator`, locator→qualname/entity-id candidate helpers. Defines the + `ToolClient` **port**. +- `git.py` — `backfill` (full history → change_events), `ingest_commit` (single commit, post-commit + hook), commit-meta/name-status parsing, `resolve_commit`/`is_ancestor`/`commits_between` + (reachability primitives consumed by S3 verification). +- `reresolve.py` — `sweep_reresolve_sei` (Rung 1c): pages null-SEI entity keys and heals them via + `store.reresolve_entity_key_sei`. + +**Dependencies:** +- Inbound: S4 (`commands`), S3 (`_blast`, `verification` via the reachability fns), S2 (`snapshot`), + **S6** (`federation` → `loomweave`), S7 (`cli`), S8 (`dogfood`). +- Outbound: `loomweave` → stdlib only; `git` → `locators` (S1), `loomweave` (S5), `store` (S2); + `reresolve` → `store` (S2), `loomweave` (S5). + +**Patterns Observed:** Subprocess-isolated sibling access (no in-process import of loomweave — +deployment-independent); `Protocol` ports decouple callers from the concrete subprocess client; +fail-soft probes return status objects, never raise into the read path. + +**Concerns:** `loomweave.py`'s hand-rolled `selectors`-based stdio JSON-RPC client (`LoomweaveMcpClient`, +~170 LOC) is non-trivial concurrency/IO code; a deadlock/timeout bug here degrades every +graph-enriched tool. Worth focused tests (see 05). + +**Confidence:** **High** — docstrings + symbol maps + every call site read in `commands.py`/`store.py`. + +--- + +## S6 — Federation Enrichment Seams + +**Location:** `src/warpline/{federation,siblings}.py` (~609 LOC) + +**Responsibility:** The optional cross-member consults that enrich a worklist when +`include_federation=true` — filigree work-state, wardline risk dossier, legis governance read — each +honest about its own availability. + +**Key Components:** +- `federation.py` — `consult_federation` (the HARD SEAM); `WardlineDossierClient`, + `LegisGovernanceClient` (over `legis governance-read `), the `RiskClient`/`LegisClient` + **ports**; `LegisGovernanceUnavailable`; `federation_transport_blockers`. Each member returns its + own weft-reason; a member with no transport is honestly `disabled`, never silently dropped. +- `siblings.py` — filigree work seam (SEAM 2 inbound, ADR-029 entity-association reverse-lookup over + HTTP): `FiligreeWorkClient`, `work_enrichment_for_sei`, `priority_from_work`, the `WorkClient` + **port**, and `RenameFeed` (rename-aware timeline stitch). + +**Dependencies:** +- Inbound: S4 (`commands`, `cop`), S3 (`reverify` imports `WorkClient`/`RenameFeed`), S7 (`mcp`). +- Outbound: **S1** (`federation` → `listing.reason`, `federation.py:38`), **S5** (`federation` → + `loomweave.loomweave_resolve_qualnames`, `federation.py:39`), intra-S6 (`federation` → `siblings`, + `:40`); plus stdlib (`urllib` for filigree HTTP, `subprocess` for legis/wardline CLIs). *(Corrected + from "stdlib only" after validation.)* + +**Patterns Observed:** Capability-gated wiring (the legis client is wired only when the installed +legis advertises `governance-read`; until then `disabled`, not forced `unreachable`); +**governance-as-echo** (legis `content_hash` echoed verbatim, never re-derived — GV-LG-1); +schema-mirrored contracts (`contracts/governance_read.v1.schema.json` mirrored byte-for-byte from +legis as the owner). + +**Concerns:** Three distinct sibling transports (filigree HTTP, legis CLI, wardline CLI) each with +their own failure surface and parsing; the consult fan-out is the second-most-complex flow after +`reverify_worklist`. Contract drift risk is managed by mirrored schemas + consumer rejection tests +(good), but the seam is inherently brittle to sibling CLI/HTTP changes. + +**Confidence:** **High** — docstrings + symbols + CHANGELOG (legis/wardline consumer entries) + +`commands.py`/`mcp.py` call sites. + +--- + +## S7 — Interface Surfaces + +**Location:** `src/warpline/{cli,mcp,mcp_smoke}.py` (~1623 LOC) + +**Responsibility:** Translate a transport (argparse CLI / JSON-RPC stdio) into `commands.py` calls and +back; verify the live MCP surface. + +**Key Components:** +- `cli.py` (601 LOC) — `build_parser` (every subcommand/flag/exit code), `main`; thin payload + builders for the `cop`/`co-change` demo verbs; constructs the optional loomweave/sei clients. +- `mcp.py` (777 LOC) — JSON-RPC `dispatch`, `_build_tools` (the 8 tool specs with endorsed name + + shim + metadata: read/write posture, idempotency, repo requirement, touched paths, federation + deps), per-tool handlers `_h_*`, `WarplineError` → `warpline.error.v1` mapping. +- `mcp_smoke.py` — `run_mcp_smoke`: live stdio round-trip smoke test. + +**Dependencies:** +- Inbound: process entry points (`pyproject.toml` scripts), S8 (`dogfood` drives `mcp.dispatch`). +- Outbound: `cli` → S8 (`dogfood`, `productization`, `install`, `install_support`), S5 (`git`, + `loomweave`, `reresolve`), S2 (`store`), S1 (`envelope`), S4 (`commands`, `cop` local), **S3** + (`coupling.classify_confidence` via a function-body import, `cli.py:136`), `mcp_smoke`. `mcp` → S4 + (`commands`), S1 (`errors`), S6 (`federation`, `siblings`). + +**Patterns Observed:** **Two surfaces, one core** — both delegate to `commands.py`, guaranteeing +CLI/MCP parity. Tool metadata is declarative (posture/idempotency/paths advertised, not inferred). +Endorsed-name + shim aliasing returns identical schema+data. + +**Concerns:** `cli.py`'s `main` (fan_out 31) is a large dispatch switch; the two surfaces duplicate +some argument-coercion logic (`mcp._*_arg` vs `cli` argparse types) — minor, inherent to dual +surfaces. + +**Confidence:** **High** — symbol maps + docstrings + the `commands.py` contract they call. + +--- + +## S8 — Lifecycle & Productization + +**Location:** `src/warpline/{install,install_support,productization,dogfood}.py` (~1238 LOC) + +**Responsibility:** Out-of-band concerns: federation member install/doctor, git-hook installation, +release-readiness decisioning, and the dogfood evaluation harness. + +**Key Components:** +- `install_support.py` (513 LOC) — `install`/`doctor` components: `.mcp.json` (Claude Code) + Codex + config bindings, CLAUDE.md/AGENTS.md instruction-block injection (foreign blocks preserved), + skill copy, gitignore, atomic writes, symlink rejection. `CheckResult`/`Component` model. +- `install.py` (27 LOC) — `install_hook` (the git post-commit hook body). +- `productization.py` — reads `spike/REPORT.md` → `ProductizationDecision` (solo vs federation + readiness thresholds). +- `dogfood.py` (575 LOC) — `run_dogfood_evaluator`: synthetic lanes + a real-member lane that drives + the full change → reverify loop against an actual loomweave index; gates on `ready=True`. + +**Dependencies:** +- Inbound: S7 (`cli`). +- Outbound: `dogfood` → S4 (`commands`), **S2** (`store`, `snapshot`, `dogfood.py:22-23`), S5 + (`git`, `loomweave`), S7 (`mcp.dispatch`); `install_support` → **S2** (`store.WARPLINE_GITIGNORE_CONTENTS`, + `install_support.py:25`) + intra-S8 (`install`); `install`/`productization` → stdlib. *(Corrected + from "stdlib only" after validation.)* + +**Patterns Observed:** Idempotent, atomic, foreign-block-preserving installer (never clobbers a +sibling's config); dogfood exercises the *real* loop end-to-end as an executable readiness gate. + +**Concerns:** `dogfood.py` (575 LOC) re-implements some git/tool-call plumbing locally +(`_git`, `_call_tool_stdio`) that overlaps S5/S7 — acceptable for a test harness but a drift risk. +`productization.py` reads a hard-coded `spike/REPORT.md` / `/tmp` default path — fine for an internal +release tool, not a general API. + +**Confidence:** **High** — docstrings + symbols + README/CHANGELOG context. + +--- + +## Dependency summary (subsystem level) + +``` +S1 Contract ◄── S3,S4,S5,S6,S7 ──► (intra-S1 only; no outbound to S2-S8) +S2 Store ◄── S3,S4,S5,S7,S8 ──► S3 (store→coupling, lazy) / snapshot→S5 +S3 Compute ◄── S2,S4,S7 ──► S1,S2,S5,S6 +S4 Commands ◄── S7,S8 ──► S1,S2,S3,S5,S6 (widest fan-out) +S5 Seams ◄── S2,S3,S4,S6,S7,S8 ──► S1,S2 (subprocess to loomweave/git) +S6 Federation◄── S3,S4,S7 ──► S1,S5,intra-S6 (+ HTTP/CLI to siblings) +S7 Surfaces ◄── entry points,S8 ──► S1,S2,S3,S4,S5,S6,S8 +S8 Lifecycle ◄── S7 ──► S2,S4,S5,S7 +``` + +Layering is **mostly** clean and **module-level acyclic** (loomweave `module_circular_import_list` = 0), +but it is *not* a strict single-direction stack: + +- **S2↔S3 back-edge (the one real coupling smell).** `propagation`/`_blast` (S3) → `store` (S2), and + `store` (S2) → `coupling` (S3) via lazy function-body imports. The code keeps the *module* graph + acyclic only because `coupling.py` is a pure leaf and `store`'s import is deferred — an explicit + workaround (`store.py:1468`) for what is, at the subsystem level, a cycle. This is the coupling the + quality assessment (05) examines. +- **Intentional acyclic "upward" reaches:** S8→S7 (`dogfood` drives `mcp.dispatch`), S3→S6 + (`reverify` imports seam *ports*), S7→S3 / S2→S3 (lazy `coupling` imports). diff --git a/docs/arch-analysis-2026-06-28-0728/03-diagrams.md b/docs/arch-analysis-2026-06-28-0728/03-diagrams.md new file mode 100644 index 0000000..961c94a --- /dev/null +++ b/docs/arch-analysis-2026-06-28-0728/03-diagrams.md @@ -0,0 +1,206 @@ +# 03 — Architecture Diagrams + +Mermaid diagrams for `warpline` at HEAD `def6d43`. Render in any Mermaid-aware viewer (GitHub, VS Code, +mermaid.live). + +--- + +## C4 L1 — System Context + +Where warpline sits in the Weft federation and its environment. warpline is **enrich-only** and +**local-first**: it functions with no sibling present and writes only under `.weft/warpline/`. + +```mermaid +flowchart TB + agent["AI Agent / Developer
(MCP host or terminal)"] + subgraph weft["Weft federation (sibling tools, all optional)"] + loom["loomweave
(graph + SEI authority — 'now')"] + filigree["filigree
(issue tracker / work-state)"] + wardline["wardline
(trust-boundary / attest risk)"] + legis["legis · plainweave
(governance / policy)"] + end + git[("git repository
(history)")] + store[(".weft/warpline/warpline.db
SQLite temporal store")] + + agent -->|"CLI / MCP tools"| warpline(["warpline
temporal change-impact authority"]) + warpline -->|"reads history"| git + warpline -->|"reads/writes (only this)"| store + warpline -.->|"SEI + edges (subprocess)"| loom + warpline -.->|"work-state (HTTP)"| filigree + warpline -.->|"attest risk (CLI)"| wardline + warpline -.->|"governance read (CLI)"| legis + warpline ==>|"advisory facts (never gates)"| agent + + classDef opt stroke-dasharray:4 3; + class loom,filigree,wardline,legis opt; +``` + +*Dashed = optional sibling, consulted only when present; absence is reported explicitly +(`unavailable`), never as a clean state.* + +--- + +## C4 L2 — Containers (process & store boundaries) + +warpline ships two executables over one shared core, plus an embedded SQLite store and an isolated +loomweave subprocess. + +```mermaid +flowchart TB + subgraph host["warpline package (one wheel, zero runtime deps)"] + cli["CLI surface
warpline.cli:main
(argparse)"] + mcp["MCP server
warpline.mcp:main
(JSON-RPC / stdio)"] + core["Command core
commands.py
(8 tool bodies)"] + dom["Domain core
store + pure compute"] + cli --> core + mcp --> core + core --> dom + end + db[("SQLite
warpline.db (WAL)
.weft/warpline/")] + loomproc["loomweave serve
(subprocess, stdio JSON-RPC)"] + gitproc["git
(subprocess)"] + + dom -->|"sqlite3"| db + dom -->|"LoomweaveMcpClient"| loomproc + dom -->|"subprocess"| gitproc + + hook["git post-commit hook
warpline ingest-commit"] --> cli + session["Claude SessionStart hook
warpline session-context"] --> cli +``` + +--- + +## C4 L3 — Components (the 8 subsystems) + +Layered flow toward the foundation; **module-acyclic** (one S2↔S3 back-edge, dotted below). (See +02-subsystem-catalog.md for module membership.) + +```mermaid +flowchart TD + S7["S7 Interface Surfaces
cli · mcp · mcp_smoke"] + S8["S8 Lifecycle & Productization
install · install_support · productization · dogfood"] + S4["S4 Command Orchestration
commands · cop"] + S3["S3 Domain Compute (pure)
_blast · propagation · _completeness
coupling · verification · _attest · reverify"] + S6["S6 Federation Enrichment Seams
federation · siblings"] + S5["S5 Resolution & Ingestion Seams
loomweave · git · reresolve"] + S2["S2 Temporal Store
store · snapshot"] + S1["S1 Contract & Envelope Foundation
errors · envelope · _enrichment
listing · refs · locators"] + + S7 --> S4 + S7 --> S8 + S7 --> S2 + S8 --> S4 + S8 --> S5 + S8 --> S2 + S4 --> S3 + S4 --> S5 + S4 --> S6 + S4 --> S2 + S3 --> S2 + S3 --> S5 + S3 --> S6 + S6 --> S5 + S5 --> S2 + S4 --> S1 + S3 --> S1 + S5 --> S1 + S6 --> S1 + S7 --> S1 + S2 -. "lazy: store→coupling" .-> S3 + S7 -. "lazy: cli→coupling" .-> S3 + + S1:::foundation + classDef foundation fill:#e8f0fe,stroke:#4264d0; +``` + +*S1 (Contract Foundation, highlighted) and S2 (Store) are **parallel** foundations — neither imports +the other; S2 has no module-level internal imports (fan_in 38, fan_out 0), which anchors the graph. +The **dotted edges are the S2↔S3 back-edge**: `store`/`cli` reach into `coupling` (S3) via deferred +function-body imports, so the module graph stays acyclic (`coupling` is a pure leaf) while the +subsystem graph has a real `store`↔`compute` cycle. Verified by `analysis-validator` against the raw +import statements (loomweave's graph omits function-body imports). For readability two real edges are +elided: S7 (Surfaces) also reaches S5/S6 directly, and S8→S7 (`dogfood.py:20` drives `mcp.dispatch`).* + +--- + +## Data model — SQLite schema + +8 base tables (`store.py:35-107`) + **2 migration-added tables** (`co_change_pairs` v3, +`verification_events` v4) + v2 anchor *columns* on `change_events`. Note: `co_change_pairs` and +`snapshot_edges` reference `entity_key_id` **without a `FOREIGN KEY`** — integrity is maintained in +application code. + +```mermaid +erDiagram + repos ||--o{ entity_keys : "repo_id" + repos ||--o{ commit_refs : "repo_id" + repos ||--o{ change_events : "repo_id" + repos ||--o{ edge_snapshots : "repo_id" + entity_keys ||--o{ change_events : "entity_key_id (FK)" + edge_snapshots ||--o{ snapshot_edges : "snapshot_id (FK)" + entity_keys ||..o{ snapshot_edges : "src/tgt id (no FK)" + entity_keys ||..o{ co_change_pairs : "a/b id (no FK)" + + repos { TEXT id PK } + entity_keys { INT id PK "locator, sei(nullable), first/last_seen_commit" } + commit_refs { TEXT sha PK "parents_json, author, authored_at, committed_at" } + change_events { INT id PK "commit_sha, path, change_kind, actor, changed_at; v2: detected_*" } + edge_snapshots { INT id PK "commit_sha, source, source_version, completeness" } + snapshot_edges { INT snapshot_id "source/target_entity_key_id, edge_kind, confidence" } + co_change_pairs { TEXT repo_id "entity_key_id_a/b, co_change_count, last_co_change" } + verification_events { INT id PK "commit_sha, kind, verified_at, actor, source" } + meta { TEXT key PK "value (schema_version, throttle markers)" } + health_log { INT id PK "code, message, created_at" } +``` + +--- + +## Sequence — the core change → reverify loop + +The flow an agent runs before claiming a change is done. Shows the always-on lazy snapshot capture and +the fail-soft sibling consults. + +```mermaid +sequenceDiagram + autonumber + participant A as Agent + participant C as commands.py (S4) + participant ST as WarplineStore (S2) + participant G as git (S5) + participant L as loomweave (S5) + participant F as siblings/federation (S6) + + A->>C: change_list(rev_range) + C->>G: rev_range_commits + C->>ST: list_change_events + ST-->>C: changed entities (+ next_actions) + C-->>A: warpline.change_list.v1 + + A->>C: reverify_worklist(changed_refs, depth, include_federation?) + C->>ST: resolve_changed_inputs → key_ids + alt no usable snapshot AND loomweave reachable + C->>L: probe + capture_edge_snapshot (lazy, fail-soft) + L-->>ST: snapshot_edges + else loomweave absent + Note over C: throttle marker set, fall through to NO_SNAPSHOT (honest) + end + C->>ST: blast_radius (pure BFS over edges) + C->>ST: verification freshness (git reachability) + opt include_federation + C->>F: consult filigree / wardline / legis (read-only) + F-->>C: per-member facts + weft_reason (or disabled/unreachable) + end + C-->>A: warpline.reverify_worklist.v1
(items + verification_summary + risk + completeness) +``` + +--- + +## Diagram notes & caveats + +- Diagrams reflect `src/` at HEAD `def6d43`; the L3 component graph is derived from import blocks + + the loomweave edge graph (tombstone `heddle.*` edges excluded). +- The ER diagram simplifies column lists; authoritative DDL is `store.py:35-107` + the `_migrate_v*` + functions. The `||..o{` (dotted) relations denote integer references **without** a DB-level foreign + key (a noted fragility — see 02/05). +- The sequence diagram omits the list-ergonomics pipeline (filter/sort/overflow/page) applied to every + list result and the `build_envelope` step common to all tools. diff --git a/docs/arch-analysis-2026-06-28-0728/04-final-report.md b/docs/arch-analysis-2026-06-28-0728/04-final-report.md new file mode 100644 index 0000000..69977fd --- /dev/null +++ b/docs/arch-analysis-2026-06-28-0728/04-final-report.md @@ -0,0 +1,132 @@ +# 04 — Final Report + +**Subject:** `warpline` v1.2.0 — Weft federation temporal / change-impact authority +**Scope:** `src/warpline/` (30 modules, ~10.4k LOC) at HEAD `def6d43` +**Date:** 2026-06-28 · **Deliverable tier:** C (Architect-Ready) +**Overall confidence:** **High** (core modules read in full; structure cross-checked against the +loomweave 2667-edge graph) + +--- + +## Executive summary + +warpline is a **small, dense, exceptionally disciplined** Python service. In ~10k lines and **zero +runtime dependencies** it implements a temporal change-impact engine over git history and a loomweave +edge graph, exposed identically through a CLI and an MCP server. Its defining quality is not size but +**rigor**: a frozen, hub-owned wire contract; closed vocabularies enforced by assertion; an "honesty +invariant" that makes the *absence* of a fact (peer down vs. peer-present-but-empty vs. true-negative) +a first-class, machine-distinguishable value everywhere; and forward-only SQLite migrations with real +concurrency and corruption-recovery handling. + +The architecture is a clean **layered + ports-and-adapters** design: a pure, testable domain core +(SQLite store + side-effect-free compute) sits behind a command-orchestration layer, reaches all +external systems through `typing.Protocol` ports, and is fronted by two thin transport surfaces. The +module-import graph is **acyclic**; the one structural blemish is a **subsystem-level S2↔S3 back-edge** +(`store` reaches into the `coupling` compute module via deliberate lazy imports), kept module-acyclic +only by a documented workaround. + +The two real architectural pressure points are **concentration**, not disorder: `store.py` (1863 LOC, +one 40-method class) and `commands.reverify_worklist` (276 LOC, fan-out 34) hold a disproportionate +share of the system's behavior. Neither is broken; both are the natural candidates for the next +refactor and the places where future change will be slowest and riskiest. Detailed quality findings +are in **05**; the prioritized improvement path is in **06**. + +**Verdict:** a production-stable (`Development Status :: 5`), well-engineered system whose debt is +*localized and known* rather than diffuse. The risk profile is "a few load-bearing hotspots," not +"pervasive rot." + +--- + +## What the system does (one paragraph) + +Given a git diff, warpline answers: **which entities changed, by whom, when; what is downstream-affected +over the call/reference graph; and what must be re-verified before the change is called done.** It +*owns* per-entity change history keyed on loomweave SEI (the one fact no sibling stores) and the +propagation query over edge snapshots it captures from loomweave. It is **advisory only** — it enriches +and deconflicts, never gates or enforces policy — and **local-first** — it boots and answers with no +sibling installed, writing only under `.weft/warpline/`. + +--- + +## Architecture at a glance + +| Dimension | Finding | +| --- | --- | +| Style | Layered + ports-and-adapters (hexagonal); `Protocol` ports for every external system | +| Layers | S1 Contract → S2 Store → S3 Pure Compute → S4 Commands → {S5 Resolution, S6 Federation} → S7 Surfaces; S8 Lifecycle out-of-band | +| Coupling | Module-acyclic (one S2↔S3 subsystem back-edge: `store`→`coupling`, lazy); foundation hub `store.py` (fan_in 38, fan_out 0); orchestration hub `commands.py` | +| Persistence | Embedded SQLite (WAL), forward-only migrations (v1→v4), schema-presence-floor recovery | +| Interfaces | CLI (argparse) + MCP (JSON-RPC/stdio), both over one `commands.py` core → guaranteed parity | +| Contract | 8 frozen `warpline..v1` + `warpline.error.v1` (11 closed codes); hub-owned interface-lock | +| Dependencies | **Zero** runtime deps (pure stdlib) | +| Quality gates | `mypy --strict`, ruff, pytest + 14 golden contract vectors, dogfood eval harness | + +--- + +## Key strengths (evidence-based) + +1. **Contract-first discipline.** Every tool returns one frozen envelope; error/enrichment/reason + vocabularies are *closed* and asserted (`errors.py:52-53`, `envelope.py:12-20`). A `v2` is a new + URI, never a `v1` mutation. This is the most defensible part of the design. +2. **The honesty invariant.** `absent` (peer present, no fact) ≠ `unavailable` (peer unreachable) ≠ + transport error ≠ clean state — encoded structurally, not by convention. Degraded results carry + `{reason_class, cause, fix}` over 11 closed classes (`listing.py:14-33`). An empty answer is never + byte-indistinguishable from an earned true-negative. This is rare and valuable. +3. **A genuinely pure domain core.** `blast_radius` does no I/O; `verification`/`reverify` take + injected callbacks/ports. The compute layer (S3) is unit-testable without a DB or git — the kind of + seam most systems claim and few achieve. +4. **Serious persistence engineering.** Forward-only migrations under `BEGIN IMMEDIATE`, concurrent-open + safety (`busy_timeout` + re-read under RESERVED lock), a schema-presence floor that re-runs + migrations when a version marker isn't backed by on-disk objects, UTC-normalized ordering to dodge + mixed-tz lexical-sort bugs (`store.py:1140-1191`), and a strictly-read-only binding probe. This is + not toy SQLite usage. +5. **Operational honesty under degradation.** Fail-soft advisory side effects (lazy snapshot capture, + attest hashing) never block or fake a read; an unreachable loomweave degrades to `NO_SNAPSHOT`, a + throttle marker prevents repeated spin-up cost, and recovery is retried automatically. +6. **Zero supply chain.** No runtime dependency means no transitive CVE surface — a real, deliberate + security/operability win for a tool meant to be installed broadly. + +--- + +## Key risks (summary — detail in 05) + +1. **`store.py` god-module** (1863 LOC: DDL + migration runner + binding probe + 40-method class). + Cohesive but oversized; the locus of slowest future change. **Med.** +2. **`reverify_worklist` god-function** (276 LOC, fan-out 34, ≥8 orchestrated concerns). The system's + complexity peak; hard to test below the integration level. **Med-High.** +3. **Manual referential integrity on FK-less tables** (`co_change_pairs`, `snapshot_edges`) in the + ~270-LOC `_merge_into_twin` family — correct today, fragile to future edits. **Med.** +4. **Read-path `except Exception` swallows** (lazy capture, attest, session_context) — robust by + intent, but they can hide genuine bugs; observability covers the store path (`health_log`) but not + these read swallows. **Low-Med.** +5. **Orchestration glue stranded in `commands.py`** rather than the S3 compute layer (lazy-capture + throttle, attest hashing, federation enrichment merge, verification cache). **Low-Med.** +6. **Sibling-seam brittleness** (S6): three transports (filigree HTTP, legis/wardline CLI) each with + their own parsing/failure surface — mitigated by mirrored schemas + consumer rejection tests. **Low.** + +> The independent `architecture-critic` and `debt-cataloger` passes (see 05 and `temp/`) refine +> severity and add specifics; this list is the synthesized view. + +--- + +## Notable archaeology + +- **Renamed from `heddle`.** The loomweave index still carries `heddle.*` tombstone entities; the DB + was migrated across the rename. Cosmetic, but explains the dual names in graph queries. +- **Built in "Rungs."** The code documents an explicit incremental ladder (Rung 1a frozen schema → 1b + anchor columns → 1c SEI self-heal → 1d lazy capture; Rung 2 Track A co-change → B verification + freshness → C risk/governance enrichment → D COP frame), governed by numbered PDRs. This is a + deliberately, traceably evolved system. + +--- + +## How to read this analysis + +| If you want… | Read | +| --- | --- | +| The holistic lay of the land | `01-discovery-findings.md` | +| Per-subsystem detail + dependencies | `02-subsystem-catalog.md` | +| Visual context / container / component / data model / flow | `03-diagrams.md` | +| Code-quality assessment + debt inventory | `05-quality-assessment.md` | +| What to do next (prioritized) | `06-architect-handover.md` | +| Validation evidence | `temp/validation-*.md`, `temp/debt-catalog.md` | diff --git a/docs/arch-analysis-2026-06-28-0728/05-quality-assessment.md b/docs/arch-analysis-2026-06-28-0728/05-quality-assessment.md new file mode 100644 index 0000000..8198451 --- /dev/null +++ b/docs/arch-analysis-2026-06-28-0728/05-quality-assessment.md @@ -0,0 +1,350 @@ +# 05 — Architecture Quality Assessment + +> Scope: `src/warpline/` (30 modules, ~10.4k LOC) at HEAD `def6d43`. Assessor: architecture-critic +> (SME protocol). Evidence-based, severity-rated, no diplomatic softening. Inputs verified against +> `01-discovery-findings.md` and `02-subsystem-catalog.md` plus direct source reads. + +## Verdict + +**Overall Quality Score: 4 / 5 — Good.** +**Critical issues: 0. High issues: 2 (both maintainability/testability, heavily mitigated).** + +Two genuine structural problems hold this below a 5: a 1863-LOC god-module (`store.py`) and a +276-LOC god-function (`reverify_worklist`), plus a cluster of Medium observability/fragility gaps +concentrated in the command-orchestration layer. None are defects shipping today; all are +future-edit hazards. They sit on top of an otherwise disciplined, contract-first codebase with an +exceptional correctness story — zero runtime dependencies, `mypy --strict`, a roughly 1:1 +test-to-source ratio (~10,780 test LOC across 59 files vs 10,411 source LOC), clean layering with zero import +cycles, a rigorously-enforced honesty invariant, and a clean security posture — which is why the +structural problems read as a tax rather than a failure. + +The score is anchored to the rubric: no Critical; the two High findings are the "monolith" example +the rubric names, mitigated by cohesion, zero internal fan-out, and heavy test coverage; the Mediums +are real but do not multiply into a 3. Security came back clean, which was the only input that could +have moved this below 4. + +| Component | Score | Critical | High | Notes | +|-----------|-------|----------|------|-------| +| S1 Contract foundation | 5/5 | 0 | 0 | Closed vocabularies, honesty invariant; one Low I/O leak | +| S2 Store (`store.py`) | 4/5 | 0 | 1 | God-module; otherwise outstanding migration/merge discipline | +| S3 Compute | 5/5 | 0 | 0 | Pure, DI-tested core; the cleanest layer | +| S4 Commands (`commands.py`) | 3/5 | 0 | 1 | `reverify_worklist` god-function + Medium cluster concentrates here | +| S5 Resolution seams | 4/5 | 0 | 0 | Hand-rolled stdio JSON-RPC client is the untested risk | +| S6 Federation seams | 4/5 | 0 | 0 | Best-in-codebase honest degradation; inherently brittle to siblings | +| S7 Interface surfaces | 4/5 | 0 | 0 | Parity by convention; default duplication (Low) | +| S8 Lifecycle | 4/5 | 0 | 0 | `dogfood.py` re-implements S5/S7 plumbing (drift risk) | + +--- + +## Findings by focus area + +### Focus 1 — `store.py` god-module & `reverify_worklist` god-function + +#### F1. `commands.reverify_worklist` is a 276-LOC orchestration god-function — **High** + +- **Evidence:** `commands.py:793-1069`. Single function, fan_out **34** (highest in the system), + orchestrating ≥8 distinct concerns inline: ref resolution (`815-821`), lazy capture (`824`), blast + (`825-826`), per-entity verification-freshness with an inline cache and two git-reachability + closures (`836-887`), stale-first presort (`908-914`), risk-as-verification SEI/locator capture + (`934-945`), federation enrichment merge (`954-974`), attest content-hashing (`1002-1014`), + impact-completeness (`986-992`), the list pipeline, and envelope assembly. +- **Impact:** High cyclomatic complexity. The function cannot be unit-tested below the integration + level — every concern is entangled with the open store handle and the others' intermediate state. + This is *where reliability bugs hide*: two of the Medium findings below (F4 throttle gap, F5 + positional invariant) live inside this function's orbit precisely because the concentration makes + the invariants hard to see. The careful inline comments (`830-835`, `900-907`, `927-933`, + `964-974`) are doing the work that extracted, individually-tested helpers should be doing. +- **Recommendation:** Extract the cohesive blocks into S3 (the pure compute layer) where they belong + and can be unit-tested: a `VerificationResolver` (closures + cache, `863-887`), the SEI/locator + capture (`934-945`), and keep `reverify_worklist` as thin wiring. The compute is already pure-ish; + it is the *assembly* that is stranded in S4. Target: no tool body over ~80 LOC. + +#### F2. `store.py` is a 1863-LOC god-module mixing four concerns — **High** (maintainability) + +- **Evidence:** `store.py` holds (a) the frozen `SCHEMA` DDL (`35-107`), (b) the migration runner + + schema-presence floor (`123-630`), (c) the read-only `read_store_binding` probe (`306-402`), and + (d) the 40-method `WarplineStore` data-access class (`633-1863`). fan_in 38, fan_out 0 internal. +- **Impact:** This is a genuine problem, not "acceptable cohesion" — but the cost is bounded and the + module is *defensibly* cohesive (it is all persistence; the zero internal fan-out is the evidence + it is a true foundation, and the 40 methods do share the `self.conn` invariant). The cost is a + navigation/merge-conflict tax and a single-file blast radius for any persistence edit. It does + **not** block growth today and carries strong compensating controls (see Strengths). Rate it High + on the maintainability axis, not as a reliability defect. +- **Recommendation:** Split along the four seams already visible in the file: `store_schema.py` + (`SCHEMA` + `MIGRATIONS` + `_run_migrations` + `_schema_presence_floor`), `store_binding.py` + (`read_store_binding` + `StoreBinding`), and `store.py` (`WarplineStore`). The merge family + (`reresolve_entity_key_sei` → `_merge_into_twin` → `_repoint_*`, `770-1054`, ~270 LOC) is a natural + fourth unit (`store_identity_merge.py`) given its high-stakes, self-contained nature. + +### Focus 2 — Fail-soft `except Exception`: robustness vs silent-failure debuggability + +The fail-soft pattern is **not monolithic** — it splits into two tiers with very different +observability, and the split is the finding. + +**Honest tier (a genuine strength — see S2 below):** the federation seams catch broad `Exception` +but capture the exception text *in-band* into the response's `weft_reason.cause`: +`federation.py:248` (`filigree consult raised: {exc!r}`), `:279` (wardline), `:315` (legis). The +degraded result is honest **and** traceable — the opposite of a silent failure. `git.py`'s +fail-soft helpers (`is_ancestor`, `commits_between`, `resolve_commit`) likewise distinguish +"could not compute" (None) from a real answer. + +**Silent tier (the finding):** + +#### F3. Read-path swallows discard the exception with no trace anywhere — **Medium** (observability) + +- **Evidence:** `commands.py:674` (`_lazy_capture_if_missing`) and `:707` (`_attest_content_hashes`) + both `except Exception: return` with **no** `health_log` write, **no** `logger` call, and **no** + in-band capture of `exc`. `session_context` (`:77`) does the same. Contrast: `health_log` is + written from the *hook* paths (`cli.py:456,474`) and the store-internal paths + (`store.py:421-427,1452,1510,1670`), but never from these read-path swallows. +- **Impact:** Debuggability, not correctness. The honesty invariant holds downstream — a swallowed + lazy-capture degrades to `NO_SNAPSHOT`, a swallowed attest-hash to `attestation_incomplete` — so + output is never wrong. But the **cause** is lost. An operator whose loomweave is subtly broken + (malformed neighborhood, handshake regression) sees a permanently degraded result with no recorded + reason and no way to distinguish "loomweave absent" from "loomweave present but erroring." The + codebase already has the right pattern two layers over (federation's `{exc!r}` capture); these + three sites simply do not use it. +- **Recommendation:** Route these swallows through `store.log_health(repo, "LAZY_CAPTURE_FAILED", + repr(exc))` / `"ATTEST_HASH_FAILED"`. The store handle is already open in scope. This makes the + read-path swallows as observable as the hook-path ones already are. + +#### F4. Lazy-capture throttle does not cover post-probe capture failures — **Medium** (latency + observability) + +- **Evidence:** `_lazy_capture_if_missing` (`commands.py:615-675`) records the throttle marker + (`_record_lazy_capture_attempt`, `:649`) **only** on the `probe.status != "available"` branch. If + the probe reports available but `capture_edge_snapshot` (`:661`) raises, control falls to the outer + `except Exception: return` (`:674`) which records **nothing** and clears **nothing**. The module + docstring (`:551-560`) claims "a failed/unavailable probe records a lightweight throttle marker" — + but a capture-time failure is neither failed-probe nor recorded. +- **Impact:** On a repo where loomweave is reachable but capture consistently fails (a DB-write error, + or a structural error in the loomweave response that escapes the per-entity guard), every + subsequent read re-pays the full `LoomweaveProbe` spin-up (~1-5s, per the module's own latency + note) **plus** the failing capture, forever, silently. The window is narrow — per-entity + `neighborhood()` failures are caught and converted to `DELTA` failures inside + `snapshot.py:144`, which still writes a snapshot and clears the marker (a real strength) — so the + triggers are DB-level write failures and structural response errors, not ordinary loomweave + flakiness. But within that window the cost is unbounded and invisible. +- **Recommendation:** Move `_record_lazy_capture_attempt(store)` to the outer `except` (or wrap the + capture so any failure stamps the marker), so the throttle covers *all* failure modes its docstring + claims to. Combine with F3's `log_health`. + +### Focus 3 — Manual referential integrity on FK-less derived tables + +#### F6. The `_merge_into_twin` family maintains integrity by hand on FK-less tables — **Medium** (fragility), implementation **sound** + +- **Evidence:** `co_change_pairs` (`store.py:182-194`) and `snapshot_edges` (`store.py:91-99`) key on + `entity_key_id` integers with **no** `FOREIGN KEY`. Integrity across a SEI re-resolution merge is + maintained entirely in Python: `_merge_into_twin` (`831-901`), `_repoint_co_change_pairs` + (`903-990`), `_repoint_snapshot_edges` (`992-1054`), all inside one `BEGIN IMMEDIATE` txn. +- **Adjudication — the implementation is correct, and that deserves credit:** globally-unique + `AUTOINCREMENT` `entity_keys.id` makes the cross-table repoint sound (no id reuse to alias an edge + onto the wrong entity); collision handling is canonical (co-change counts are *summed*, recency + kept via `_later_marker`'s "non-null beats null" rule `430-448`, self-pairs dropped `:946-948`); + `change_events` collisions delete the null-keyed duplicate (M5) with **documented, intentional** + data loss of divergent `hunk_summary`/`actor` (`:868-873`, Q7) — acceptable on a convergent + self-heal path, not a defect. The repoint runs *before* the null-key DELETE (`:896-900`), so no + dangling reference window exists. +- **Impact:** The risk is not today's correctness — it is that ~270 LOC of high-stakes surgery + replaces what a DB-level `FOREIGN KEY ... ON ... ` or a generated-column constraint would enforce + for free. A future contributor adding a third `entity_key_id`-referencing table, or altering the + canonical-ordering rule, must manually replicate all three collision modes (self, re-canonicalize, + collide) or silently corrupt the graph. SQLite FKs are off by default and the project chose not to + enable them; that choice is now load-bearing on reviewer vigilance. +- **Recommendation:** Either (a) enable `PRAGMA foreign_keys=ON` and add real FKs with + `ON DELETE`/repoint semantics where SQLite supports them, accepting the merge still needs manual + re-canonicalization; or (b) keep manual but add a `_assert_no_orphans` debug invariant run in tests + after every merge, so a future edit that breaks integrity fails a test rather than ships. + +#### F7. `_repoint_snapshot_edges` docstring claims self-edge collapse the code does not do — **Low** + +- **Evidence:** `store.py:999` docstring: "collapse a source==target self-edge. `INSERT OR IGNORE` …" + But `INSERT OR IGNORE` (`:1040-1054`) only collapses **duplicate PK** rows, not a self-edge: when + an edge `(source=null_key, target=twin)` is repointed it becomes `(source=twin, target=twin)` and + is inserted as a self-edge. The sibling `_repoint_co_change_pairs` *does* explicitly drop self-pairs + (`:946-948`); `snapshot_edges` has no equivalent `new_source == new_target` guard. +- **Impact:** Benign **today**: `blast_radius`'s BFS dedups on a `seen` set (`propagation.py:81-83`), + so a `twin→twin` self-edge is skipped (twin is always already seen) and never produces a spurious + affected entry. But the spurious row persists in `snapshot_edges` and would surface to any consumer + that reads edges directly, and the doc/code mismatch is exactly the maintenance trap F6 warns about + — the asymmetry between the two repoint paths reads as intentional when it is not. +- **Recommendation:** Add the `if new_source == new_target: continue` guard to match + `_repoint_co_change_pairs`, or correct the docstring. One line either way. + +### Focus 4 — Dual-surface (CLI/MCP) over one `commands.py` + +**Strength:** "two surfaces, one core" is real and well-executed. Both `cli.py` and `mcp.py` delegate +to the same `commands.py` functions, so business-logic parity is *structural*, not tested-into-place. +Tool metadata is declarative (`mcp.py:_build_tools`), endorsed-name + shim return identical +schema+data, and golden contract vectors pin the envelope (`tests/contracts/test_golden_vectors.py`, +`test_reverify_worklist_schema.py`). + +#### F8. Input defaults are independently re-specified per surface — **Low** (duplication) + +- **Evidence:** The numeric defaults exist in **three** places that must stay in sync by convention: + the `commands.py` signature defaults (`depth=2`, `limit=50/100`), the MCP coercers + (`mcp.py:351-391` `_depth_arg` defaults 2, `_limit_arg(args, 50)` / `(args, 100)`), and the + `cli.py` argparse types. They match today. +- **Impact:** The "identical schema+data" guarantee covers the *core path* but **not** the per-surface + input coercion/defaults, which are re-encoded. A future change to a default in one surface drifts + silently from the others. I did **not** find a test asserting numeric-default equality across CLI + and MCP (the golden vectors pin envelope *shape*, not cross-surface default parity) — though I did + not exhaustively read all 59 test files, so state this as "not confirmed present," not "absent." +- **Recommendation:** Source defaults from one module-level constant table imported by both surfaces, + or add a parity test that drives the same args through both and asserts equal envelopes. + +### Focus 5 — Coupling / cohesion / separation of concerns / testability + +- **Layering is clean and the module-import graph is acyclic** (`module_circular_import_list` = 0). + *Reconciliation note (post-validation):* `02-subsystem-catalog.md` was corrected after this + assessment to record one **subsystem-level S2↔S3 back-edge** — `store` (S2) lazily imports + `coupling` (S3) at `store.py:1468-1469,1554` (and `cli.py:136`), while `propagation`/`_blast` (S3) + import `store` (S2). It stays *module*-acyclic only because `coupling` is a pure leaf and the import + is deferred — an explicit workaround. This is a minor coupling smell, not a cycle that breaks the + build; it does not change the F1/F2 severities but it is the kind of layer-bleed the F4/F2 split work + should tidy. The other upward reaches (S8→S7 dogfood drives `mcp.dispatch`; S3→S6 `reverify` imports + seam *ports*) are intentional and acyclic. +- **The pure compute layer (S3) is the architecture's best decision** — DI via callbacks/Protocols + (`verification.compose_verification_freshness` takes `covers`/`between`; `reverify` takes a + `WorkClient`) keeps it unit-testable with no DB or git. This is why S3 scores 5/5. +- **But the orchestration glue that *feeds* S3 is stranded in S4** (`_lazy_capture_if_missing`, + `_attest_content_hashes`, `_merge_federation_enrichment`, `_member_scalar`, the `verification_for` + cache). These reusable-looking helpers live in the command module, not the compute layer, which is + the structural root of F1. The compute is clean; its assembly is concentrated. + +#### F5. Unenforced positional invariant across the `_blast`/`commands` boundary — **Medium** (correctness-fragility) + +- **Evidence:** `commands.py:836-843` builds `changed_key_ids`/`affected_key_ids` from + `result["changed"]`/`result["affected"]` by **position**, then aligns them positionally to the + frozen `changed`/`affected` entity views (which deliberately drop `entity_key_id` for + SEI-orthogonality). Correctness depends on `enrich_blast` (`_blast.py:142-159`) iterating the same + source lists in the same order with no filter — enforced **only** by code comments + (`commands.py:830-835` "verified _blast.py:142-157"; `_blast.py:142-143`). +- **Impact:** The verification-freshness block is attached to entities by index. A future edit that + filters one side but not the other (e.g., `enrich_blast` skips a row with a null `entity_key_id` + while the key-id extraction does not) silently misattaches the *wrong verification state to the + wrong entity* — a correctness bug with no exception, caught only by a test that happens to exercise + a filtered case. The frozen view dropping `entity_key_id` is the right call (SEI-orthogonality); the + *parallel-list* compensation is the fragile part. +- **Recommendation:** Carry `entity_key_id` in a private (non-frozen) field on the enriched rows so + the alignment is by-key, not by-index, eliminating the positional dependency; or add an assertion + (`len(changed) == len(changed_key_ids)` plus a per-row id echo) so a future divergence fails loudly. + +### Focus 6 — Other genuinely notable items + +#### F9. Read-only git verbs lack a `--` options terminator before refs/paths — **Low** (security hardening) + +- **Evidence:** `resolve_commit` (`git.py:95`), `commits_between` (`:130`), `_commits` (`:39-43`), + `is_ancestor` (`:109-114`) interpolate caller-supplied refs into argv without a `--` + end-of-options separator. +- **Impact:** No shell injection exists anywhere (verified: zero `shell=True`/`os.system`/`os.popen`; + argv-list throughout; SEI/qualname/path passed as discrete argv elements `federation.py:77,176`, + `loomweave.py:52,155`; SQL fully parameterized with the only interpolation being int-coerced + `PRAGMA user_version = {int}`). The residual is *argument*-injection: a ref/path beginning with `-` + consumed as a git flag. Bounded to read-only verbs (`rev-parse`/`rev-list`/`merge-base`/`log`/ + `show`) with no code-execution primitive, and the "attacker" must already be the local CLI/MCP + caller — outside warpline's local-first, single-tenant threat model. This is a hardening nit, not a + vulnerability, and does **not** move the score. +- **Recommendation:** Insert `"--"` before ref/path arguments (`["rev-parse", "--verify", "--quiet", + "--", f"{ref}^{{commit}}"]` where the verb supports it), as defense-in-depth. + +#### F10. `listing.py` mixes pure predicates with filesystem overflow-spill — **Low** (carried from catalog) + +- **Evidence:** `02-subsystem-catalog.md:54` (S1 Concerns) — `apply_overflow` writes a file inside an otherwise + side-effect-free contract module (S1). Assessed via catalog, not line-by-line (see Information Gaps). +- **Impact:** A small I/O leak into the layer that is otherwise the purest in the system; complicates + unit-testing the list pipeline in isolation. +- **Recommendation:** Inject the spill sink (a writer callback) so the predicate pipeline stays pure. + +#### F11. `loomweave.py`'s hand-rolled `selectors`-based stdio JSON-RPC client is the untested risk — **Low-Medium** + +- **Evidence:** `LoomweaveMcpClient` (`loomweave.py:91-229`, ~170 LOC of `subprocess.Popen` + + `selectors` non-blocking I/O + deadline handling). A deadlock/timeout bug here degrades every + graph-enriched tool. `02-subsystem-catalog.md:228` (S5 Concerns) flags it; `test_loomweave_probe.py` exists + but I did not confirm it exercises the concurrency/timeout paths. +- **Recommendation:** Targeted tests for the deadline, partial-read, and process-death paths. + +--- + +## Strengths (genuine, evidence-cited — not token positivity) + +1. **Clean security posture.** Zero runtime dependencies (`pyproject.toml:24`), no `shell=True` / + `os.system` / `os.popen` anywhere, argv-list subprocess invocation throughout, fully parameterized + SQL (only interpolation is int-coerced `PRAGMA user_version`). For a tool that shells out to git + + three sibling CLIs, this is the right posture and it is held consistently. +2. **The honesty invariant is real and rigorously enforced end-to-end**, including in degradation: + closed `ENRICHMENT_VOCAB`/reason/error vocabularies (`envelope.py:12-20`, `errors.py`, + `listing.py:14-33`), and the federation seams capture the failing exception *in-band* + (`federation.py:248,279,315`) so degradation is honest **and** debuggable. `absent` is never + conflated with `unavailable`. +3. **Migration discipline is sophisticated.** Forward-only, `BEGIN IMMEDIATE` per-step atomicity, + concurrent-open safety via re-read under the RESERVED lock (`store.py:611-630`), and the + standout `_schema_presence_floor` (`:469-509`) that refuses to trust a `meta.schema_version` + marker the on-disk objects do not back up — defending against a lying marker is a level of rigor + most projects never reach. +4. **The identity-merge surgery is correct** (F6): globally-unique key ids, canonical collision + handling, recency-preserving, no dangling-reference window, with intentional+documented data loss. +5. **`read_store_binding`** (`store.py:306-402`) is a correctly-designed strictly-read-only probe + (`mode=ro`, creates nothing, fails closed on out-of-vocab status at `commands.py:1446-1449`), + properly distinct from the lazily-creating `open()` — the right tool for stale-binary detection. +6. **The pure S3 compute core + Protocol ports** make the analytical heart unit-testable without + infrastructure. +7. **Test investment is exceptional**: ~1:1 test-to-source ratio (~10.8k test LOC), 59 files, including contract + golden vectors, an honesty-invariant suite, migration tests, and reresolve/merge tests. + +--- + +## Confidence Assessment + +**Overall confidence: High.** I read in full: `commands.py` (1486 LOC), `store.py:1-632` and +`:770-1054` (schema, migration runner, presence floor, the entire merge family), `read_store_binding`, +`propagation.py`, `_blast.enrich_blast`, `snapshot.py:110-169`, `federation.py:230-318`, +`siblings.py:40-79`, `git.py:1-135`, and the MCP handler defaults. Findings F1-F9 are grounded in +directly-read file:line evidence and verified control flow (F4 and F5 were traced, not inferred). F7 +was confirmed by reading both repoint functions and the BFS consumer. Security claims were verified by +exhaustive grep (`shell=True`/`os.system`/`os.popen` → none) plus reading every subprocess argv site. + +**Lower confidence (Medium):** F10 (`listing.py` I/O leak — taken from the catalog, not read +line-by-line) and F11 (`loomweave` client test coverage — existence of `test_loomweave_probe.py` +confirmed, contents not read). The F8 negative ("no cross-surface default-parity test") is stated as +*not confirmed present* rather than *absent* because I did not read all 59 test files. + +## Risk Assessment + +- **Highest residual risk: F5 (positional invariant).** It is the only finding that can produce a + *silent correctness* failure (wrong verification state on the wrong entity) rather than a degraded- + but-honest one. Likelihood is low (requires a specific future edit) but detection is poor. +- **Operational risk: F4 (throttle gap).** Narrow trigger window, but within it the cost is unbounded + and invisible — the kind of issue that surfaces as "warpline got slow" with no diagnostic trail. +- **Maintenance risk: F1 + F2 + F6.** The two god-units plus the manual FK integrity concentrate + future-edit hazard. They do not threaten today's correctness; they threaten the *next* contributor's + ability to change persistence or the reverify flow without regression. +- **No security risk identified** within the local-first, single-tenant threat model. F9 is hardening. +- **Inherent (accepted) risk:** S6's three-transport sibling fan-out (filigree HTTP, legis CLI, + wardline CLI) is brittle to sibling interface changes by design; mitigated by mirrored schemas + + consumer-rejection tests. Not a warpline-side defect. + +## Information Gaps + +- `store.py:1056-1863` (co-change update path, churn aggregation, snapshot read methods) was assessed + via signatures, docstrings, and the catalog's method inventory — **not** line-by-line. Findings do + not depend on its internals, but I cannot certify that region at the same confidence as `:1-1054`. +- `listing.py`, `cop.py`, `cli.py`, and the S8 modules were assessed via catalog + call sites, not + full reads. F10 inherits the catalog's confidence. +- I did not run the test suite or measure actual coverage; the ~1:1 ratio is a LOC count, not a + branch-coverage figure, and I did not verify that F4/F5/F7's specific paths are tested. +- The `loomweave` JSON-RPC client's concurrency/timeout behavior (F11) was not exercised or read. + +## Caveats + +- Severities are rated against the rubric's objective definitions, deliberately resisting the + symmetric temptation to inflate Mediums to Highs to "look rigorous." F3/F4 are Medium and *heavily + mitigated by the honesty invariant* (downstream output stays correct); they are observability and + latency issues, not correctness. F7 and F9 are correctly Low (neutralized by the BFS `seen`-set and + the argv-list posture respectively). I have stated each mitigation so the severities read as earned. +- The two High findings are maintainability/testability concentrations, **not** defects. Nothing here + ships broken. The 4/5 reflects a system that is correct and disciplined today but carries two + structural decisions (the god-units) and one fragile invariant (F5) that will tax or trip future + edits. +- This assessment critiques architecture quality only. Cataloguing the debt items as tracked tickets, + and sequencing the recommended refactors, are out of scope (route to debt-cataloger / + prioritize-improvements). diff --git a/docs/arch-analysis-2026-06-28-0728/06-architect-handover.md b/docs/arch-analysis-2026-06-28-0728/06-architect-handover.md new file mode 100644 index 0000000..c65b757 --- /dev/null +++ b/docs/arch-analysis-2026-06-28-0728/06-architect-handover.md @@ -0,0 +1,143 @@ +# 06 — Architect Handover + +**Purpose:** transition from *analysis* to *improvement planning*. This unifies the independent +architecture-critic findings (`05-quality-assessment.md`, F1-F11) and the debt catalog +(`temp/debt-catalog.md`, D1-D12) into one de-duplicated, prioritized, sequenced backlog an architect or +maintainer can act on. + +**Headline for the decision-maker:** warpline is a **4/5, healthy, contract-first system with zero +shipping defects**. The work below is *future-edit hazard reduction*, not firefighting. It is small in +absolute terms (one High, a handful of Mediums) and the two highest-value items are **cheap** +(a property test; three log lines). There is no rewrite here — the architecture is sound. + +--- + +## 1. Verdict recap + +| Axis | Finding | +| --- | --- | +| Overall quality | **4/5 — Good** (architecture-critic, SME-rated) | +| Critical / High / Med / Low | 0 Critical · 1 High (data-integrity, `D2/F6`) · ~6 Medium · ~6 Low | +| Shipping defects | **None.** Every finding is a maintainability, observability, latency, or fragility hazard for *future* edits | +| Security | **Clean** within the local-first, single-tenant model (zero deps, no shell, argv-list, parameterized SQL) | +| Best decisions | Pure S3 compute + Protocol ports; the honesty invariant; `_schema_presence_floor` migration rigor; zero supply chain | +| Worst decisions | Concentration: `store.py` (god-module) + `reverify_worklist` (god-function); manual FK integrity | + +--- + +## 2. Finding reconciliation (critic F ↔ debt D) + +The two independent passes agree strongly; the critic added four items the debt pass didn't (F4, F5, +F7, F9), and the debt pass added items the critic folded into prose (D4, D5, D7, D8, D10). Unified: + +| Unified ID | Item | Critic | Debt | Severity (reconciled) | +| --- | --- | --- | --- | --- | +| **U1** | FK-less derived tables — manual referential integrity | F6 | **D2** | **High** (only data-integrity item) | +| **U2** | Positional invariant `_blast`↔`commands` (silent mis-attribution) | **F5** | — | Medium (highest *residual* risk — silent-correctness) | +| **U3** | Silent read-path `except Exception` swallows (no breadcrumb) | F3 | D6 | Medium (best effort/value ratio) | +| **U4** | Lazy-capture throttle misses capture-time failures | **F4** | — | Medium (latency + observability) | +| **U5** | `reverify_worklist` god-function (276 LOC, fan_out 34) | F1 | D3 | Medium-High | +| **U6** | `store.py` god-module (1863 LOC, 4 concerns) | F2 | D1 | Medium | +| **U7** | Orchestration glue stranded in S4 (feeds U5) | (Focus 5) | D4 | Medium | +| **U8** | `loomweave` stdio client test-gap / timeout fragility | F11 | D12 | Medium | +| **U9** | Attest N+1 loomweave round-trips (per-SEI) | — | D5 | Medium (perf, attest path only) | +| **U10** | Per-surface input default / arg-coercion duplication | F8 | D9 | Low | +| **U11** | `_repoint_snapshot_edges` self-edge: docstring ≠ code | **F7** | — | Low | +| **U12** | `dogfood.py` re-implements S5/S7 plumbing | — | D7 | Low | +| **U13** | Hardcoded `/tmp` + `spike/REPORT.md` default paths | — | D8 | Low | +| **U14** | git verbs lack `--` options terminator | **F9** | — | Low (hardening) | +| **U15** | `listing.py` FS overflow-spill in pure S1 layer | F10 | D11 | Low | +| **U16** | Unraised `WarplineError` subclasses (frozen-vocab) | — | D10 | Low (likely intentional — *document, don't delete*) | + +Plus a **housekeeping** item outside `src/` scope: + +| **U17** | 3 `test_attest.py` HighEntropyHex loomweave findings are **false positives** (content-addressing hashes + a synthetic `bbbb…` fixture + a commit SHA used as test data, not credentials — verified). `.env` is git-ignored (OK). | Waive in the loomweave/wardline baseline. | + +--- + +## 3. Recommended sequence + +Ordered by **(value × inverse effort)**, then by risk class. Each is behavior-preserving and gated by +the existing (~1:1, 59-file) test suite. + +### Wave 1 — cheap, high-leverage (do first; ~1-2 days total) + +1. **U3 + U4 — read-path observability (S, Medium).** Route the three silent swallows + (`commands.py:77,674,707`) through `store.log_health(repo, "", repr(exc))`, and move the + throttle-marker stamp into the outer `except` of `_lazy_capture_if_missing`. *Removes a whole class + of invisible field degradation.* Note: the `# noqa: BLE001` annotations are currently **inert** (BLE + is not in ruff's `select`) — either add `BLE` to the select set or drop the misleading comments. +2. **U1 — FK-integrity invariant (M, High).** Do **not** rewrite the schema (SEI-orthogonality is + deliberate, `store.py:177`). Add a `_assert_no_orphans` debug/CI invariant + a property test that + runs the full `_merge_into_twin` family against a fixture and asserts every `*_entity_key_id` + resolves. Converts a silent-corruption risk into a loud test failure. +3. **U2 — eliminate the positional invariant (S-M, Medium, highest residual risk).** Carry + `entity_key_id` in a private (non-frozen) field on the enriched rows so verification state aligns + *by key*, not by index (`commands.py:836-843`); or assert `len`+per-row id echo. This is the only + finding that can fail *silently incorrect* — fix it before it can be tripped. +4. **U11 + U14 + U16 — one-line hygiene.** Add the `new_source == new_target` guard (or fix the + docstring) in `_repoint_snapshot_edges`; insert `"--"` before git refs in the read-only verbs; + annotate the three reserved error subclasses as frozen-vocab (don't delete). +5. **U17 — waive the 3 test_attest false-positive secrets** in the loomweave/wardline baseline. + +### Wave 2 — structural (the refactors; sequence matters) + +6. **U6 — split `store.py`** along its four visible seams: `store_schema.py` (DDL + migrations + + presence-floor), `store_binding.py` (`read_store_binding`/`StoreBinding`), `store_identity_merge.py` + (the `_merge_into_twin`/`_repoint_*` family — naturally pairs with the U1 invariant), and `store.py` + (the `WarplineStore` read/write methods). Mechanical, behavior-preserving. +7. **U5 + U7 — extract `reverify_worklist` assembly into S3.** Move the verification resolver (cache + + `_covers`/`_between` closures), the SEI/locator capture, and the stranded glue + (`_lazy_capture_if_missing`, `_attest_content_hashes`, `_merge_federation_enrichment`) down into the + pure compute layer / a `reverify_assembly` seam. Leaves `reverify_worklist` as thin wiring (target + ≤ ~80 LOC) and unlocks unit tests for each concern. Do *after* U6 so the store seams are stable. +8. **U8 — harden + test the loomweave client.** Add a read deadline and focused tests for partial + reads, EOF/broken-pipe, oversized frames, and timeout (a hang here degrades every graph-enriched + tool). + +### Wave 3 — opportunistic (low priority; do when touching the area) + +9. **U9** batch the attest loomweave resolve (add a batch call to `LoomweaveMcpClient`). +10. **U10** centralize per-surface defaults/coercion in one shared table. **U12** reuse S5/S7 from + `dogfood`. **U13** derive temp paths from `tempfile.gettempdir()`. **U15** inject the `listing` + overflow-spill sink. + +--- + +## 4. What NOT to do + +- **Do not add naive FK/CASCADE to the derived tables.** It would fire mid-merge and break the + intentional SEI-orthogonal repoint. U1's fix is a *test invariant*, not a schema change. +- **Do not delete the three unraised error subclasses (U16).** They pin entries of the frozen + `warpline.error.v1` vocabulary; removing them is a contract change. +- **Do not "fix" the federation broad-`except` swallows.** They already capture `{exc!r}` in-band into + `weft_reason.cause` — that is the *correct* pattern (and the model U3 should copy), not debt. +- **Do not refactor for its own sake.** This is a 4/5 system; Wave 1 captures most of the value. + +--- + +## 5. Decision points for the owner + +1. **Adopt FKs or keep manual?** (U1) Recommended: keep manual + add the invariant test. Confirm. +2. **Refactor budget.** Wave 1 is ~1-2 days and high-value; Waves 2-3 are optional structural + investment. Decide whether the god-unit splits (U5/U6) are worth the churn now or deferred until the + next feature touches them. +3. **Bridge to the tracker.** This repo uses **filigree**. Recommended: promote U1-U8 to filigree + issues (U1/U2/U3 as P1; U5/U6/U8 as P2), labelled `arch-analysis-2026-06-28`. The codebase also has + **warpline itself** (`reverify`) and **wardline** (`scan`) gates — run them before/after each + refactor wave. + +## 6. Next-step tooling + +- **Prioritization deep-dive:** `axiom-system-architect:prioritize-improvements` (this handover is its + input). +- **Per-refactor planning:** `axiom-planning:implementation-planning` for U5/U6 (the structural moves). +- **Validation evidence** for this analysis: `temp/validation-catalog.md` (catalog gate, PASS-WITH-FIXES, + all fixes applied), `temp/debt-catalog.md` (full debt inventory), and `05-quality-assessment.md` (the + SME critique with confidence/risk/gaps). + +--- + +*Analysis package complete: `00`-`06` + `temp/`. Confidence: High. Scope: `src/warpline/` at HEAD +`def6d43`. The two structural Highs (U5/U6) and the silent-correctness Medium (U2) are the items most +worth an architect's attention; everything else is hygiene.* diff --git a/docs/arch-analysis-2026-06-28-0728/temp/debt-catalog.md b/docs/arch-analysis-2026-06-28-0728/temp/debt-catalog.md new file mode 100644 index 0000000..7b5c146 --- /dev/null +++ b/docs/arch-analysis-2026-06-28-0728/temp/debt-catalog.md @@ -0,0 +1,75 @@ +# Technical Debt Catalog — `warpline` (`src/warpline/`) + +**Scope:** `src/warpline/` only — 30 `.py` files, ~10,411 LOC, +flat package, zero runtime deps, Python 3.12, `mypy --strict`, `ruff`. `tests/` and the +sibling `heddle.*` repo are **out of scope** and excluded from every row below. + +**Source basis:** subsystem catalog `../02-subsystem-catalog.md`; full read of +`commands.py` and `store.py:1–1342`; targeted reads of `store.py` merge family +(770–1045) and schema DDL (37–230); grep sweeps for fail-soft swallows, hardcoded +paths, batching markers, arg-coercion; loomweave `entity_dead_list` / `entity_todo_list`. + +**Headline:** this is a **disciplined, healthy codebase** — zero deps, `mypy --strict`, +`ruff`, no circular imports, **no TODO/FIXME/HACK/XXX markers anywhere in src**, correct +forward-only migration + `BEGIN IMMEDIATE`/rollback transaction discipline, honest +fail-soft degradation as first-class return values. The debt below is overwhelmingly +**Med/Low structural-cohesion and observability** debt, with a **single High** (data +integrity). The catalog is deliberately not inflated; see Confidence/Caveats. + +--- + +## Debt Inventory + +| ID | Item | Location (file:line) | Category | Severity | Effort | Remediation | +|----|------|----------------------|----------|----------|--------|-------------| +| D1 | `store.py` god-module: one 1863-LOC file holds schema DDL, the migration runner, `_schema_presence_floor`, the read-only `read_store_binding` probe, **and** the 40-method `WarplineStore` data-access class. | `store.py:37–230` (DDL/migrations), `store.py:306` (`read_store_binding`), `store.py:469` (`_schema_presence_floor`), `store.py:633–1863` (`WarplineStore`) | god-module | Med | L | Split package: `store/schema.py` (SCHEMA + migrations + presence-floor), `store/binding.py` (`read_store_binding`/`StoreBinding`), `store/identity.py` (the reresolve/merge family), `store/store.py` (the read/write methods). Pure mechanical extraction; no behavior change. | +| D2 | FK-less derived tables — referential integrity maintained **only** by hand in the identity-merge path. `co_change_pairs` has **no** FK to `entity_keys`; `snapshot_edges` has an FK on `snapshot_id` but **none** on `source_entity_key_id`/`target_entity_key_id`. A future edit to the ~270-LOC merge family can silently orphan rows — `PRAGMA foreign_keys=ON` (store.py:652) gives no backstop on those columns. | tables: `store.py:91` (`snapshot_edges`), `store.py:184` (`co_change_pairs`); manual integrity: `reresolve_entity_key_sei` `store.py:770–829`, `_merge_into_twin` `store.py:831–902`, `_repoint_co_change_pairs` `store.py:903–991`, `_repoint_snapshot_edges` `store.py:992–1045` | coupling (data-integrity) | **High** | M | Keep the SEI-orthogonal design (deliberate per `store.py:177`) — do **not** naively add FKs/CASCADE that would fire mid-merge. Instead add a **post-merge referential-integrity invariant**: a `_assert_no_orphans` check (debug/CI) + a property test that runs the full merge against a fixture and asserts every `*_entity_key_id` resolves. Turns a silent-corruption risk into a loud test failure. | +| D3 | `reverify_worklist` complexity hotspot — 276 LOC, fan_out 34, orchestrates ≥8 concerns in one body: ref resolution, lazy capture, blast, per-entity verification-freshness (inline `_verif_cache` + two git-reachability closures `_covers`/`_between`), federation enrichment merge, attest content-hashing, impact-completeness, the list pipeline, and envelope assembly. Hard to unit-test below integration level. | `commands.py:793–1069`; verification assembly `commands.py:836–887`; attest/sei collection `commands.py:927–1014` | complexity | Med | L | Extract the verification-freshness assembly (`changes_by_key` build, `_covers`/`_between`, `_verif_cache`, `verification_for`) into S3 (`verification.py` or a new `reverify_assembly.py`); extract the affected-SEI/locator collection + attest hashing. Leave `reverify_worklist` as thin wiring. Unlocks unit tests for each concern. | +| D4 | Orchestration glue stranded in S4 `commands.py` that arguably belongs in the S3 compute layer / a seam-assembly module — reusable-looking helpers living in the command module. | `_lazy_capture_if_missing` `commands.py:615–675`, `_attest_content_hashes` `commands.py:678–709`, `_member_scalar` `commands.py:1091–1110`, `_merge_federation_enrichment` `commands.py:1112–1154`, inline verification cache `commands.py:873–887` | coupling (layering) | Med | M | Move these down into S3 / a dedicated assembly seam so `commands.py` is pure wiring (`resolve → open store → compute → list → envelope`). Improves testability and keeps the layer boundary the subsystem catalog documents. | +| D5 | N+1 sibling round-trips in `_attest_content_hashes` — one `entity_resolve` loomweave subprocess JSON-RPC call **per SEI** in a loop; the docstring itself flags "batching is a clean later optimization". Only paid when an `attest_bundle` is supplied, but a large worklist × subprocess-per-SEI is slow. | `commands.py:678–709` (loop `698–704`; admission `commands.py:690`) | perf | Med | M | Add a batch resolve to `LoomweaveMcpClient` (single round-trip for N locators) and call it once here instead of per-SEI. Bounded blast radius (attest path only), so schedule after the structural items. | +| D6 | Silent fail-soft swallows with **no observability breadcrumb** — broad `except Exception` that returns a degraded value with no log/metric/warning emitted, so a persistently-failing dependency degrades invisibly. The `# noqa: BLE001` annotations are **inert** (BLE is not in `ruff` `select=["E","F","I","UP","B"]`, pyproject `72`). | `session_context` `commands.py:77`, `_lazy_capture_if_missing` `commands.py:674`, `_attest_content_hashes` `commands.py:707`; sibling-probe swallows `loomweave.py:115,262,315`, `siblings.py:54,65` | observability | Med | S | On each swallow path emit a structured breadcrumb (a `health_log` row, stderr diagnostic, or an envelope `warning`) so silent degradation is detectable in the field. Narrow the `except` to the expected transport/IO classes where feasible. Cheapest high-value item. | +| D7 | `dogfood.py` re-implements git + tool-call plumbing locally — `_git` and `_call_tool_stdio` duplicate S5 (`git.py` reachability/run helpers) and S7 (`mcp.dispatch`). 575-LOC harness; the local copies can drift from the shipped seams. | `_call_tool_stdio` `dogfood.py:525`, `_git` `dogfood.py:568` (vs `git.py`, `mcp.dispatch`) | duplication | Low | M | Reuse `git.py` primitives and call `mcp.dispatch` in-process where the harness doesn't specifically need a fresh subprocess. Acceptable for a test harness, but note the drift risk if kept. | +| D8 | Hardcoded non-portable default paths in lifecycle tooling — `/tmp/...` (not Windows-portable, not multi-user-safe) and a fixed `spike/REPORT.md` report location baked into defaults. | `productization.py:7` (`/tmp/...results.json`), `productization.py:20` & `cli.py:371` (`spike/REPORT.md`), `dogfood.py:24` (`/tmp/...results.json`), `dogfood.py:79` (`/tmp/...work`) | coupling (config/portability) | Low | S | Derive temp defaults from `tempfile.gettempdir()`; make the report path a required/config'd argument rather than a baked default. Internal tooling, so Low. | +| D9 | Argument-coercion duplicated across the two surfaces — `mcp.py` hand-rolls `_*_arg` coercion/validation while `cli.py` re-expresses the same via argparse `type=`. Two places to keep in sync for repo/depth/limit/key-ids. | `mcp.py:330–379` (`_repo_arg`/`_entity_ref_arg`/`_depth_arg`/`_key_ids_arg`/`_limit_arg`) vs `cli.py:188–374` (`type=Path/int` per subcommand) | duplication | Low | M | Centralize coercion+validation in one shared module both surfaces call. Partly inherent to dual surfaces (argparse vs JSON-RPC), so Low priority. | +| D10 | Declared-but-unraised error subclasses — three `WarplineError` subclasses are defined but **never raised** anywhere in `src/` or `tests/`. | `errors.py:76` (`InvalidRepoError`), `errors.py:124` (`PeerUnavailableError`), `errors.py:130` (`SnapshotUnavailableError`) | dead-code | Low | S | Either raise them at the conditions they name, or annotate as **reserved frozen-vocab error codes** (`warpline.error.v1`). **Caveat: very likely intentional contract surface** — these subclasses pin entries of the frozen error vocabulary (`errors.py:52` asserts code membership). Confirm intent before removing; default to documenting, not deleting. | +| D11 | I/O leak into an otherwise-pure S1 contract module — `apply_overflow` writes an overflow-spill file from inside `listing.py`, which is otherwise pure filter/sort/page predicates. | `listing.py` (`apply_overflow`, 437-LOC module; concern per `../02-subsystem-catalog.md:54`) | coupling | Low | S | Inject the spill sink (a writer callback) so `listing` stays pure and the FS write moves to the caller/seam. Minor. | +| D12 | Test-gap / fragility — the hand-rolled `selectors`-based stdio JSON-RPC client (`LoomweaveMcpClient`, ~170 LOC) is non-trivial concurrency/IO; a deadlock/timeout/partial-read bug here degrades **every** graph-enriched tool. Includes a bare `pass`-in-`except` (`loomweave.py:115`). | `loomweave.py` (`LoomweaveMcpClient`; bare `except` `loomweave.py:115`) | test-gap | Med | M | Add focused tests for partial reads, EOF/broken-pipe, oversized frames, and timeout; add a read-deadline so a hung sibling can't wedge a read path. (Confidence Medium — characterized via subsystem catalog + grep, not a full line-by-line read.) | + +--- + +## Prioritized Top 5 + +1. **D2 — FK-less derived tables (High / M).** The only correctness-class item: a future merge-path edit can silently corrupt referential integrity with no DB backstop. Cheapest durable fix is a post-merge invariant + property test, not a schema rewrite. Do first. +2. **D6 — Silent fail-soft observability (Med / S).** Highest leverage-per-effort: small change, removes a whole class of invisible field degradation, and corrects the inert-`noqa` misconception. Quick win. +3. **D3 — `reverify_worklist` complexity (Med / L).** The system's complexity hotspot; extracting the verification + attest assembly into S3 unlocks unit testing of the most intricate flow and shrinks the widest function. +4. **D1 — `store.py` god-module split (Med / L).** Mechanical, behavior-preserving package split that makes the foundation tractable and pairs naturally with isolating the D2 merge family into its own module. +5. **D12 — Loomweave stdio client test-gap (Med / M).** Risk mitigation for a single point whose failure degrades every graph-enriched tool; add timeout + partial-read/EOF tests before this bites in the field. + +--- + +## Confidence Assessment + +- **Overall: High** for D1–D6, D8–D11 (direct file:line evidence, most from full reads of `commands.py` and `store.py:1–1342`, plus DDL and merge-family reads). +- **Medium** for D7 and D12 (characterized via the subsystem catalog + grep + signatures, not a full line-by-line read of `dogfood.py`/`loomweave.py`). +- **The "healthy codebase" assessment is itself a finding, not a gap:** zero deps, `mypy --strict`, `ruff`, no circular imports, no TODO/FIXME markers (confirmed by both `grep` and loomweave `entity_todo_list`), correct migration/transaction discipline. Severity was deliberately held down to avoid inflation. + +## Risk Assessment + +- **D2 is the only item with a correctness/data-loss failure mode** (silent referential-integrity corruption on a future merge edit). All others are maintainability, performance-under-load, observability, or hygiene — none can produce a wrong answer to a user today. +- **Severity-inflation risk** was the primary cataloging risk and was actively resisted; if anything these severities are conservative. +- Acting on D1/D3/D4 (structural moves) carries normal refactor risk — gate each behind the existing test suite; they are behavior-preserving by construction. + +## Information Gaps + +- `dogfood.py` (575 LOC) and `loomweave.py` (433 LOC) were **not** read line-by-line — D7/D12 rest on the subsystem catalog, grep, and signatures. A full read could refine effort or surface sub-items. +- `cli.py`, `mcp.py`, `federation.py`, `install_support.py` were covered via grep + docstrings + the `commands.py` contract they call, not full reads — D9 duplication is confirmed at the helper level but the exhaustive coercion-divergence list was not enumerated. +- No runtime profiling was done; **D5's perf impact is reasoned (subprocess-per-SEI), not measured.** +- Cyclomatic-complexity and fan-out figures for D3 are taken from the subsystem catalog, not independently recomputed. + +## Caveats + +- **D10 is almost certainly intentional**, not a defect — the three unraised error classes pin entries of the frozen `warpline.error.v1` vocabulary. Listed for completeness; the remediation is "document as reserved," not "delete." +- **D2's framing is "accepted tradeoff with fragility risk," not "they forgot FKs"** — the SEI-orthogonality is deliberate (`store.py:177`) and the `_repoint_*` family exists *because* manual integrity was chosen over CASCADE. +- The loomweave `entity_dead_list` was **not** used as evidence beyond D10: it is dominated by false positives — Protocol ports (`ToolClient`/`WorkClient`/`RiskClient`/`LegisClient`/`NeighborhoodClient`, referenced via type annotations not call edges), `WarplineStore` (instantiated via the `.open()` classmethod), `tests.*` (out of scope), and `heddle.*` (a **different repo**). Those are not debt. +- `store.py`'s `except BaseException: ROLLBACK; raise` handlers (`627`, `827`, `1814`) are **correct transaction discipline**, not fail-soft swallows — deliberately excluded from D6. +- `warpline_entity_key_id` was investigated as a possible advertise-and-ignore input and **cleared**: it is genuinely handled with degradation in `store.resolve_ref` (`store.py:1331–1338`). diff --git a/docs/arch-analysis-2026-06-28-0728/temp/validation-catalog.md b/docs/arch-analysis-2026-06-28-0728/temp/validation-catalog.md new file mode 100644 index 0000000..4abb180 --- /dev/null +++ b/docs/arch-analysis-2026-06-28-0728/temp/validation-catalog.md @@ -0,0 +1,166 @@ +# Validation Report — 02-subsystem-catalog.md + +**Validator:** analysis-validator (independent, evidence-based) +**Date:** 2026-06-28 +**Target:** `docs/arch-analysis-2026-06-28-0728/02-subsystem-catalog.md` +**Supporting:** `docs/arch-analysis-2026-06-28-0728/01-discovery-findings.md` +**Codebase:** `src/warpline/` @ HEAD `def6d43` (30 `.py`, ~10.4k LOC) +**Loomweave index:** `fresh`, analyzed at `def6d43` (matches HEAD) — graph metrics are trustworthy. + +--- + +## Overall Verdict: **PASS-WITH-FIXES** + +The 8-subsystem decomposition is sound, module assignments are defensible (no misfiles found), and **every spot-checked numeric/factual claim verified true against source.** However, the **dependency-direction summary contains a cluster of real, surgical inaccuracies** — the catalog repeatedly asserts "stdlib only / no internal deps" for modules that have plainly-visible internal imports. These are not cosmetic: they propagate directly into the diagram phase (which will draw the subsystem graph wrong) and the architecture-critique phase (which will miss a genuine `store → coupling` coupling smell). The dependency-section corrections are marked **REQUIRED**, not optional. + +This is not a BLOCK: the decomposition itself is correct, there is no module-level import cycle, and the fixes are enumerable edits to the dependency prose + summary diagram. + +--- + +## Checks Actually Run (so a zero-misfile result is justified) + +1. **Coverage** — enumerated all 30 `.py` files vs. the 29 catalogued modules. +2. **Module assignment** — read module docstrings/headers for `locators`, `coupling`, `_attest`, `federation`, `siblings`, `snapshot`, `reverify`, `verification` (the borderline + representative set) and cross-checked role vs. assigned subsystem. +3. **Dependency direction** — extracted every intra-package / sibling `import` statement from all 30 modules (`grep` of import blocks) and mapped importer→imported to subsystem edges; cross-checked against loomweave `entity_neighborhood_get`, `entity_coupling_hotspot_list`, `module_circular_import_list`. +4. **Claim verification** — confirmed/refuted 11 specific factual claims with file:line citations (table below). +5. **Contract conformance** — checked all 8 entries for the required field set. + +--- + +## Claims Table + +| # | Claim (from catalog / task) | Verdict | Evidence | +|---|---|---|---| +| 1 | `store.py` fan_in 38, fan_out 0 | **CONFIRMED** *(as loomweave metric)* | `entity_coupling_hotspot_list` → `warpline.store` fan_in 38 / fan_out 0. NB: loomweave does not count function-body imports (see #2). | +| 2 | `store.py` → "stdlib only (no internal deps — this is *why* it can be the foundation)" | **REFUTED** | `store.py:1469` `from warpline.coupling import derive_pairs_from_commit`; `store.py:1554` `from warpline.coupling import coupling_rate`. Two deferred (function-local) imports of `coupling` (S3). In-code comment at `store.py:1467-1468`: *"store -> coupling is the one-way edge."* | +| 3 | `reverify_worklist` 276 LOC, fan_out 34 | **CONFIRMED** | `commands.py:793-1069` (= 276 lines, loomweave `source_line_start/end`); `entity_coupling_hotspot_list` fan_out 34. | +| 4 | No circular imports (`module_circular_import_list`) | **CONFIRMED** *(module level)* | `module_circular_import_list` → `cycles: [], total: 0`. True because `coupling` imports nothing, so `store→coupling` forms no module cycle. (Does **not** clear the subsystem-level back-edge — see Finding A.) | +| 5 | 8 base SQLite tables: meta/repos/entity_keys/commit_refs/change_events/edge_snapshots/snapshot_edges/health_log | **CONFIRMED** | `store.py:37,42,48,59,68,81,91,100` — exactly these 8 in frozen `SCHEMA`. `co_change_pairs` (`:184`, v3) and `verification_events` (`:212`, v4) are migration-added, correctly distinguished by the catalog. | +| 6 | 11 closed error codes, 3 retryability values | **CONFIRMED** | `errors.py:9-23` `ERROR_CODES` frozenset = 11 codes; `errors.py:8` `RETRYABILITY` = 3 values. | +| 7 | "WarplineError base + **10** subclasses" | **PARTIALLY REFUTED (LOW)** | `errors.py` defines **11** subclasses (`:70,76,83,90,97,104,111,118,124,130,136`). Base + `InternalError` share `internal_error`, so 11 subclasses pin 11 codes. Count is off by one. | +| 8 | `co_change_pairs` has no FOREIGN KEY | **CONFIRMED** | `co_change_pairs` DDL `store.py:184-192` — PRIMARY KEY only, no FK. | +| 9 | `snapshot_edges` has no FOREIGN KEY | **PARTIALLY REFUTED (LOW)** | `snapshot_edges` **has** `FOREIGN KEY(snapshot_id) REFERENCES edge_snapshots(id)` at `store.py:98`. It has **no** FK on its `source_entity_key_id`/`target_entity_key_id` columns (`:93-94`). Catalog's *nuanced* wording ("key on entity_key_id integers with no FOREIGN KEY") is defensible; its **bolded** "No DB-level FKs on derived tables" overstates. Concern substance (manual referential integrity in merge path, `store.py:891-1042`) is **correct**. | +| 10 | `cli.main` fan_out 31 | **CONFIRMED** | `entity_coupling_hotspot_list` → `warpline.cli.main` fan_out 31. | +| 11 | `listing.reason` fan_in 16 | **CONFIRMED** | `entity_coupling_hotspot_list` → `warpline.listing.reason` fan_in 16. | + +--- + +## Dependency-Direction Findings (wrong / missing edges) + +All edges below are **plainly-visible top-level imports** unless marked "(deferred)". The catalog header states dependencies were "derived from import blocks + the loomweave edge graph," so the top-level omissions are self-inconsistent with the catalog's own stated method. + +### Finding A — `store`(S2) → `coupling`(S3): unacknowledged subsystem back-edge **[REQUIRED]** +- **Evidence:** `store.py:1469`, `store.py:1554` (deferred imports of `warpline.coupling`). +- **Refutes three catalog statements:** + 1. S2 outbound: *"store.py → stdlib only (no internal deps)"* (§S2 Dependencies). + 2. Summary diagram: `S2 Store … ──► (store: none / snapshot: S5)`. + 3. Closing claim: *"the only 'upward' reaches are S8→S7 and S3→S6 … both intentional and acyclic."* +- **Two-granularity framing (so it is neither overstated nor softened):** + - *Module level:* **no cycle** — `coupling` is a pure leaf (imports nothing); `module_circular_import_list = 0` confirms it. The catalog is right about this. + - *Subsystem level:* `store`(S2)→`coupling`(S3) **plus** `propagation`(S3)→`store`(S2) (`propagation.py:8`) and `_blast`(S3)→`store`(S2) (`_blast.py:20`) = a real **S2↔S3 back-edge**. The "single direction of flow toward the foundation, zero cycles" narrative does not hold at subsystem granularity. +- **Why it matters downstream:** a "foundation" persistence module reaching *up* into the pure-compute layer is exactly the coupling smell the architecture-critique phase needs surfaced. The catalog author missed what `store.py`'s author documented in a comment. + +### Finding B — `federation`(S6) → `listing`(S1) + `loomweave`(S5): "stdlib only" is wrong **[REQUIRED]** +- **Evidence:** `federation.py:38` `from warpline.listing import reason` (S1); `federation.py:39` `from warpline.loomweave import loomweave_resolve_qualnames` (S5). +- **Refutes:** S6 outbound *"stdlib only (urllib … subprocess …)"* and summary diagram `S6 Federation … ──► stdlib`. +- These are **top-level** edges the loomweave graph contains, so this contradicts the catalog's stated derivation method — not a deferred-import artifact. +- **Knock-on:** S5's inbound list (§S5 Dependencies) and the summary diagram `S5 Seams ◄── S2,S3,S4,S7,S8` both **omit S6→S5**. Add `S6` to S5's inbound. +- NB: `siblings`(S6) *is* genuinely stdlib-only (`siblings.py` imports only `json/os/urllib/...`); the error is specific to `federation`. + +### Finding C — `install_support`(S8) → `store`(S2): "stdlib" is wrong **[REQUIRED]** +- **Evidence:** `install_support.py:25` `from warpline.store import WARPLINE_GITIGNORE_CONTENTS` (S2); also `:24` `install` (S8 intra), `:23` `__version__`. +- **Refutes:** S8 outbound *"install_support/install → stdlib"*. + +### Finding D — `dogfood`(S8) → `store` + `snapshot`(S2): missing S8→S2 **[REQUIRED]** +- **Evidence:** `dogfood.py:22` `from warpline.store import WarplineStore, default_store_path`; `dogfood.py:21` `from warpline.snapshot import capture_edge_snapshot` (S2). +- **Refutes:** S8 outbound list and summary diagram `S8 Lifecycle … ──► S4,S5,S7` — both omit **S2**. + +### Finding E — `cli`(S7) → `coupling`(S3): missing S7→S3 **[REQUIRED]** +- **Evidence:** `cli.py:136` `from warpline.coupling import classify_confidence` (deferred, inside a function). +- **Refutes:** S7 outbound list and summary diagram `S7 Surfaces … ──► S1,S2,S4,S5,S6,S8` — both omit **S3**. + +### Finding F — S1 inbound "all other subsystems" is overstated; S1 and S2 are *parallel*, not stacked **[RECOMMENDED]** +- **Evidence:** No module in S2 imports any S1 module — even transitively. `store`→`coupling`→∅; `snapshot`→`loomweave`(S5)/`store`(S2)→… none reach S1. S8 likewise imports no S1 module directly (`install_support`→`store`/`install`/`__version__`; `dogfood`→S4/S5/S2/S7; `productization`/`install`→stdlib). +- **Refutes:** §S1 Dependencies *"Inbound: **all** other subsystems."* Actual S1 inbound = {S3, S4, S5, S6, S7}. S2 and S8 are absent. +- **Sharper statement than "the word 'all' is wrong":** the linear ordering **"S1 → S2"** is unsupported — the store layer does not sit on the contract layer. S1 and S2 are **parallel foundations**, both depended on by higher layers. (`listing.reason` fan_in 16 remains correct and is the right evidence for S1's centrality.) + +### Finding G — "imports seam *ports*" is euphemistic **[LOW]** +- **Evidence:** `reverify.py:7` imports concrete functions `priority_from_work, work_enrichment_for_sei` from `siblings`, not only the `WorkClient` Protocol. The S3→S6 edge is real (catalog flags it) but it is not purely a port import. + +--- + +## Module Assignment Spot-Check (docstring-based) + +Read module docstrings/headers; mapped stated role → assigned subsystem. + +| Module | Stated role (docstring) | Assigned | Verdict | +|---|---|---|---| +| `coupling` | "Temporal co-change coupling derivation (Rung 2 Track A). Pure derivation helpers." | S3 | ✅ correct | +| `_attest` | "Risk-as-verification consumer … pure, enrich-only … no store, no git, no I/O." | S3 | ✅ correct | +| `verification` | "Pure verification-freshness compute … no store, no git, no I/O." | S3 | ✅ correct | +| `reverify` | worklist render; imports `listing`(S1)+`siblings`(S6) | S3 | ✅ correct (see Finding G) | +| `federation` | "reverify's cross-member consult (HARD SEAM)" filigree/wardline/legis | S6 | ✅ correct | +| `siblings` | filigree HTTP work seam (urllib, stdlib) | S6 | ✅ correct | +| `snapshot` | neighborhood→`snapshot_edges` rows; `NeighborhoodClient` port | S2 | ✅ correct (bridge S5→S2) | +| `locators` | `python_entity_locators(path, source)` — pure AST locator extraction, no docstring | S1 | ⚠️ **borderline** | + +**No hard misfiles found.** `locators` is the one soft case: it is a pure, dependency-free AST helper whose only consumer is `git`(S5) ingestion (`git.py:9`). Semantically it is an ingestion/resolution helper, not a "wire contract" element, so S5 would be a defensible alternative home. Grouping it with S1's pure foundation leaves is acceptable; flagging it as borderline rather than wrong. + +--- + +## Coverage + +- **29 of 30** modules are placed across the 8 subsystems. +- **Uncatalogued:** `__init__.py` (11 LOC) — the package marker exposing `__version__`, imported by `envelope`(S1), `install_support`(S8), `cli`, `mcp`. Excluding a package `__init__` is conventional and acceptable, but it should be **explicitly acknowledged** (one line) rather than silently dropped, since it is a real import target. **[LOW]** +- `py.typed` is a 0-byte marker (not a module) — correctly excluded. + +--- + +## Contract Conformance + +All 8 subsystem entries (S1–S8) carry the required field set: **Location, Responsibility, Key Components, Dependencies (inbound + outbound), Patterns Observed, Concerns, Confidence.** Layer-order header and subsystem-level dependency summary are present. **PASS** on structure. (The defects above are accuracy-of-content, not missing-field, issues.) + +--- + +## Prioritized Required Fixes + +**REQUIRED (block the diagram + critique phases if left):** +1. **Finding A** — Correct S2 outbound: `store → coupling` (S3) exists (`store.py:1469,1554`). Remove "stdlib only / no internal deps"; update the summary diagram `(store: none)`; add `store→coupling` to the "upward reaches" list; reconcile the "zero cycles" claim by distinguishing module-level (acyclic) from subsystem-level (S2↔S3 back-edge). +2. **Finding B** — Correct S6 outbound: `federation → listing`(S1) + `loomweave`(S5). Replace "stdlib only." Add `S6` to S5's inbound list and the summary diagram. +3. **Finding C** — Correct S8 outbound: `install_support → store`(S2). Replace "stdlib." +4. **Finding D** — Add `S2` to S8 outbound (`dogfood → store/snapshot`) in both the prose and the summary diagram. +5. **Finding E** — Add `S3` to S7 outbound (`cli → coupling`) in both the prose and the summary diagram. + +**RECOMMENDED:** +6. **Finding F** — Rewrite S1 inbound from "all other subsystems" to the actual set {S3,S4,S5,S6,S7}; note S1 and S2 are parallel foundations, not stacked. + +**LOW (fix opportunistically):** +7. Claim #7 — "10 subclasses" → 11. +8. Claim #9 — reword "No DB-level FKs on derived tables" → "no FK on the `entity_key_id` columns" (snapshot_edges has a `snapshot_id` FK). +9. Coverage — add one line acknowledging `__init__.py` is intentionally uncatalogued. +10. Finding G — soften "imports seam *ports*" (reverify imports concrete `siblings` functions too). + +--- + +## Confidence Assessment + +- **High** confidence in all REFUTED/CONFIRMED claims in the table and Findings A–E: each rests on a direct file:line import statement or a loomweave graph metric from a *fresh* index at the analyzed HEAD. +- **High** confidence in "no hard misfiles" — backed by docstring reads (the method the task named), not inference alone. +- **Medium-High** on Finding F's "parallel foundations" framing — verified S2 imports no S1 module directly and traced its transitive closure (`store→coupling→∅`, `snapshot→loomweave/store`), but did not exhaustively walk every S8 transitive path beyond the direct importers. + +## Risk Assessment + +- **If shipped unfixed:** the diagram phase will render an incorrect subsystem dependency graph (missing S6→S5, S8→S2, S7→S3; false "store: none"), and the architecture-critique phase will **miss the `store→coupling` back-edge** — the single most architecturally-interesting finding here. Medium downstream risk; low correctness risk to the code itself (the code is fine; only its description is off). +- **Verdict risk:** PASS-WITH-FIXES (not BLOCK) is appropriate — decomposition and all metrics hold; defects are enumerable and localized to the dependency narrative. + +## Information Gaps + +- **"2667 edges"** (catalog header) vs. loomweave's reported **3545** total edges (`project_status_get`): plausibly a filtered subset (post-`heddle.*`-tombstone, resolved-confidence only, or import-only), but the exact filter is **unverifiable from available data**. Descriptive flourish; not load-bearing. Noted, not blocking. +- **"WarplineStore — 40 methods"**: not independently re-counted (outside the task's named spot-checks); accepted as-is. +- Subsystem-level edge claims were validated by direct `import` extraction + targeted loomweave neighborhoods, not a full all-pairs graph diff. + +## Caveats (scope of this validation) + +- I validate **structural / evidence-based accuracy**: contract conformance, cross-document consistency, dependency-direction correctness against actual imports, and the specific factual claims spot-checked. Scope was `src/warpline/` only. +- I do **not** adjudicate whether the subsystem *boundaries* are the architecturally-optimal decomposition, whether the identified *patterns* are the right abstractions, or whether the *concerns* are complete — those require the architecture-critic / refactoring-architect phase. +- loomweave's edge graph is authoritative for resolved static imports but **does not capture function-body (deferred) imports** — the root cause of the `store→coupling` and `cli→coupling` omissions. Where the graph and source disagree, I cite **source** as ground truth. diff --git a/docs/arch-analysis-2026-06-28-0728/temp/validation-final.md b/docs/arch-analysis-2026-06-28-0728/temp/validation-final.md new file mode 100644 index 0000000..bd4d4c5 --- /dev/null +++ b/docs/arch-analysis-2026-06-28-0728/temp/validation-final.md @@ -0,0 +1,200 @@ +# Final Validation Gate — Cross-Document Consistency + +**Validator:** analysis-validator (independent, evidence-based — final integration gate) +**Date:** 2026-06-28 +**Target:** full Option-C package `docs/arch-analysis-2026-06-28-0728/` (`00`–`06` + `temp/`) +**Codebase:** `src/warpline/` @ HEAD `def6d43` (30 `.py`, 10,411 LOC) +**Scope of this gate:** cross-document **integration** — did the earlier per-document fixes propagate +everywhere, are facts/numbers stated consistently, do F↔D reconciliations and cross-references hold. +Per-document content was already validated upstream (catalog gate, architecture-critic, debt-cataloger) +and was **not** re-derived here. + +> **Note on oracle:** loomweave has **no index** for this project right now (`./.weft/loomweave/loomweave.db` +> missing), so fan_in/fan_out graph metrics could not be re-queried. They were accepted as stated +> (consistent across all docs and matching the upstream catalog gate). All **dependency-edge** and +> **count** claims were re-verified directly against `src/` source — see evidence column. + +--- + +## Overall Verdict: **PASS-WITH-FIXES** + +**The package is shippable as-is.** The single biggest integration risk — that the corrected +dependency facts failed to propagate and a stale contradiction survived somewhere — is **clean**. +Every corrected edge (S2↔S3 back-edge; federation/install_support/dogfood no-longer-"stdlib-only"; +cli→coupling; S8→S2) is stated consistently across `02`/`03`/`04`/`05` **and** confirmed against +source. All seven team-lead numeric facts are identical everywhere they appear. The F↔D reconciliation +(U1–U17) is complete and correct with no drops or mis-mappings. + +What remains is **~6 one-line hygiene fixes**, all **Low** severity, none gating downstream use: +3 stale line-cites into the re-numbered catalog, one unqualified "no cycles" diagram caption, one +"3 migration-added tables" miscount (should be 2), one undisclosed diagram edge elision, and one +"three/four" prose miscount. No Critical, no High, nothing BLOCKs. + +--- + +## Consistency Checks + +| # | Check | Verdict | Evidence | +|---|-------|---------|----------| +| 1 | Corrected dependency facts confirmed in **source** | **PASS** | `store.py:1469` `from warpline.coupling import derive_pairs_from_commit` (+ comment `:1467-1468`), `store.py:1554` `coupling_rate`; `cli.py:136` `from warpline.coupling import classify_confidence`; `federation.py:38` `listing.reason` + `:39` `loomweave_resolve_qualnames`; `install_support.py:25` `store.WARPLINE_GITIGNORE_CONTENTS`; `dogfood.py:21` `snapshot`/`:22` `store`; `propagation.py:8`+`_blast.py:20` → `store` (S3→S2 half). | +| 2 | No stale "**stdlib only**" for federation / install_support / dogfood | **PASS** | `02:257-258` (S6) and `02:329-332` (S8) now list the real edges with explicit "*(Corrected from 'stdlib only' after validation.)*" notes. Remaining "stdlib only" hits are correct: `00:17` (runtime deps = 0), `02:221` (`loomweave.py` — **verified** zero `warpline` imports), and `siblings` (verified stdlib-only). | +| 3 | No stale "**single direction / zero cycles**" without the back-edge caveat | **PASS (1 caption nit → row 9)** | Caveat present in `02:6-13,359-368`, `03:115-121`, `04:24-26,57`, `05:197-205`. Module-acyclic vs subsystem back-edge distinguished consistently. Lone exception: the `03:75` caption (row 9). | +| 4 | S2↔S3 back-edge stated consistently across 02/03/04/05 | **PASS** | `02:6-13` & summary `02:351,366`; `03:108-109,118-119`; `04:24-26,57`; `05:199-203`. Same direction, same lines, same "lazy/deferred, module-acyclic" framing. | +| 5 | Numeric consistency (7 team-lead facts) | **PASS** | store **fan_in 38 / fan_out 0**: `00:23,02:70,03:116,05:66,04:57`. reverify **276 LOC / fan_out 34**: `00:24,01:141,02:185,04:29/95,05:13/43,06:39,debt:27`. **8 base tables**: `03:127,02:71-77,01;` verified `store.py:37,42,48,59,68,81,91,100`. **11 error codes + 11 subclasses**: `02:31` "11 subclasses", `01:110-111`, `04:60`; verified `errors.py` 11 `class …(WarplineError)` + `ERROR_CODES` frozenset = 11. **30 modules / 10,411 LOC**: consistent `00/01/02/04/05/debt`; verified `ls *.py`=30, `wc`=10411. **4/5**: `05:9,06:19,00:63`. **1 High debt item**: `debt:D2`, `05` (2 High *quality* findings F1/F2 ≠ debt severity — see row 10). | +| 6 | F↔D reconciliation — team-lead spot-checks | **PASS** | `06:35-50`: **U1=F6+D2** (High, FK-less) ✓; **U2=F5** (critic-only) ✓; **U5=F1+D3** ✓; **U6=F2+D1** ✓. | +| 7 | F↔D reconciliation — **full coverage** (no dropped findings) | **PASS** | All **F1–F11** map: F1→U5, F2→U6, F3→U3, F4→U4, F5→U2, F6→U1, F7→U11, F8→U10, F9→U14, F10→U15, F11→U8. All **D1–D12** map: D1→U6, D2→U1, D3→U5, D4→U7, D5→U9, D6→U3, D7→U12, D8→U13, D9→U10, D10→U16, D11→U15, D12→U8. No mis-mappings; U17 = housekeeping (test_attest false-positives). | +| 8 | Cross-references resolve (file-level) | **PASS** | `04:125-133` "how to read" table → all targets exist (`01`,`02`,`03`,`05`,`06`,`temp/validation-*`,`temp/debt-catalog`). `06:135-137` pointers exist. `05:5` → `01`,`02` exist. (Line-level cites: row 11.) | +| 9 | `03:75` "no cycles" caption | **FIX (Low)** | Row 9 below. | +| 10 | Quality "2 High" vs debt "1 High" not contradictory | **PASS** | Different axes, stated as such: `05` rates **2 High** *maintainability/testability* findings (F1/F2); `debt:D2` is the **1 High** *data-integrity* item. `06:20` reconciles: "1 High (data-integrity, D2/F6) · ~6 Medium · ~6 Low." Not a numeric conflict. | +| 11 | Line-level cross-references into the re-numbered catalog | **FIX (Low)** | Rows 11a–11c below. | +| 12 | Diagram matches catalog (L3 edges vs dependency summary) | **FIX (Low)** | Row 12 below — one undisclosed elision. | +| 13 | `03:127` table count | **FIX (Low)** | Row 13 below. | +| 14 | `06` prose item count | **FIX (Low)** | Row 14 below. | +| 15 | Contract conformance — Option-C deliverables present + structured | **PASS** | `00`–`06` all present; `temp/validation-catalog.md` + `temp/debt-catalog.md` present. Each carries its required structure (catalog: 8 entries × full field set; quality: verdict + per-component table + F1-F11 + Confidence/Risk/Gaps/Caveats; handover: verdict recap + F↔D table + sequence; debt: inventory + top-5 + Confidence/Risk/Gaps/Caveats). | + +--- + +## Stale Contradictions + +**The team-lead's #1 concern is essentially clean.** Exactly **one** residual instance of the +"unqualified no-cycles" pattern survives, and it is self-correcting within its own section: + +### Row 9 — `03-diagrams.md:75` unqualified "no cycles" on the C4 L3 **subsystem** diagram — **Low** +- **Exact location:** `03-diagrams.md:75` — *"Layered flow toward the foundation; no cycles."* +- **Contradiction:** This caption sits directly above the C4 L3 **subsystem** diagram, which (a) draws + the dotted `store→coupling` / `cli→coupling` back-edges (`03:108-109`) and (b) carries a note 43 + lines down (`03:118-119`) stating *"the subsystem graph has a real `store`↔`compute` cycle."* The + caption asserts the opposite of the diagram's own note. It is the verbatim instance of the + "zero cycles without the S2↔S3 caveat" pattern the gate was told to hunt — it just happens to be + contradicted in-section rather than left standing. +- **Fix:** qualify the caption, e.g. *"Layered flow toward the foundation; **module-acyclic** (the + dotted edges are the one subsystem-level S2↔S3 back-edge)."* + +No other stale "stdlib only" / "single direction" / "zero cycles" contradiction exists anywhere in the +package. (Checks 2–4 all PASS, source-confirmed.) + +--- + +## Mis-mappings (F↔D Reconciliation) + +**None.** The U1–U17 table is complete and correct (checks 6 + 7). The four named spot-checks verified, +and every F1–F11 and D1–D12 maps with no drops and no mis-mappings. + +One **prose** inaccuracy (the table itself is correct): + +### Row 14 — `06-architect-handover.md:30` "three items" miscount — **Low** +- **Exact location:** `06:30` — *"the critic added **three** items the debt pass didn't (F4, F5, F7, + F9)…"* — lists **four** items (F4, F5, F7, F9) but says "three." These are exactly the four + critic-only rows (U2/U4/U11/U14 carry "—" in the Debt column), so **four** is correct. +- **Secondary (same sentence):** the companion clause "*the debt pass added items the critic folded + into prose (D4, D5, D7, D8, D10)*" is loose — D4 (Focus 5) and D7 (S8 table row) *are* in `05` prose, + but D5/D8/D10 are genuinely debt-only and not in the critic's prose. The **table** correctly marks + them Critic="—"; only the prose characterization overreaches. Fix the count to "four"; optionally + tighten the prose clause. + +--- + +## Broken / Stale Cross-References + +**Root cause (single):** the catalog was re-numbered when the upstream fixes were applied (it gained a +methodology-caveat block at `02:15-18`, an expanded back-edge paragraph `02:6-13`, and corrected +S1–S5 dependency prose). All downstream line numbers shifted **down ~13–17 lines**. Three inbound +line-cites that were authored against the *pre-fix* catalog were never re-synced — so they now point at +the **wrong content**. The prose at each citing site is accurate; only the line pointer drifted. + +### Row 11a — `05:252` (F10) → `02:40-42` is stale — **Low** +- **Cited:** `02-subsystem-catalog.md:40-42` for "*`apply_overflow` writes a file … side-effect-free + contract module (S1)*." +- **Reality:** `02:40-42` is the `_enrichment.py` / `locators.py` Key-Components bullets. The + `apply_overflow` overflow-spill **Concern** is now at **`02:53-55`**. +- **Fix:** retarget to `02:53-55`. + +### Row 11b — `05:262` (F11) → `02:211-213` is stale — **Low** +- **Cited:** `02-subsystem-catalog.md:211-213` as where the catalog "*flags it*" (the hand-rolled + loomweave stdio JSON-RPC client risk). +- **Reality:** `02:211-213` is the `loomweave.py` Key-Components bullet (defines the `ToolClient` port) + + the start of the `git.py` bullet. The loomweave-client **Concern** is now at **`02:228-230`**. +- **Fix:** retarget to `02:228-230`. + +### Row 11c — `temp/debt-catalog.md:35` (D11) → `02:40-42` is stale — **Low** +- **Cited:** `../02-subsystem-catalog.md:40–42` for the same `listing.py` overflow-spill concern. +- **Reality:** same as 11a — concern is at **`02:53-55`**. +- **Fix:** retarget to `02:53-55`. + +--- + +## Other Numeric / Diagram Findings + +### Row 13 — `03-diagrams.md:127` "3 migration-added tables" should be **2** — **Low** +- **Location:** `03:127-128` — *"8 base tables … + **3 migration-added tables** (`co_change_pairs` v3, + `verification_events` v4; anchor columns v2 on `change_events`)."* +- **Issue:** Only **2** of the three listed items are tables; v2 added **columns** (`detected_*`) to + `change_events`, not a table. Verified: `store.py` has exactly 10 `CREATE TABLE` (8 base + `co_change_pairs:184` + + `verification_events:212`). The doc's **own ER diagram** (`03:133-153`) draws **10** tables, i.e. + 2 migration-added — contradicting the "3" in its own prose. +- **Fix:** *"+ 2 migration-added tables (`co_change_pairs` v3, `verification_events` v4) + v2 anchor + columns on `change_events`."* + +### Row 12 — `03` L3 diagram omits the `S8→S7` edge the catalog records, **undisclosed** — **Low** +- **Location:** `03:88-110` draws `S7→S8` but **not** `S8→S7`. The catalog records `S8→S7` + (`dogfood`→`mcp.dispatch`) in S8 outbound (`02:329`) and as an intentional upward reach (`02:367`); + confirmed in source at **`dogfood.py:20`** `from warpline.mcp import dispatch`. +- **Issue:** The diagram **discloses** its S7→S5/S6 elision (`03:121`) but is silent about omitting + `S8→S7`. Drawing it would make the `S7↔S8` subsystem round-trip visible (module-acyclic, since + `mcp.py` imports neither `cli` nor `dogfood`) — which is also why the row-9 "no cycles" caption reads + cleaner than reality. Almost certainly an intentional readability elision, **not** an error in the + catalog — but it should be disclosed like the other one. +- **Fix:** add `S8→S7` to the elision note, or draw it dotted with a one-line caption. + +--- + +## Confidence Assessment + +**Overall confidence: High** on the integration verdict. +- **High** on all dependency-edge claims (row 1) and all count claims (row 5): each rests on a directly + re-read `src/` `import` statement or a re-counted construct (`errors.py`, `store.py` DDL, `ls`/`wc`), + not on the (currently-unavailable) loomweave oracle. +- **High** on the F↔D coverage map (rows 6–7): built by reading `05` F1–F11, `temp/debt-catalog` D1–D12, + and the `06` U-table end-to-end and matching every row both directions. +- **High** on the three stale cross-references (rows 11a–c) and the `03:127`/`03` L3 findings (rows + 12–13): confirmed by reading the cited target lines directly. +- **Medium-High** on fan_in/fan_out figures themselves (row 5): re-derivation was impossible + (no loomweave index); accepted as internally consistent + matching the upstream catalog gate, which + verified them against a then-fresh index. + +## Risk Assessment + +- **If shipped unfixed:** impact is **cosmetic-to-navigational**. An architect following `05` F10/F11 or + `debt` D11 to the cited catalog lines lands on the wrong bullet (rows 11a–c); a reader trusting the + `03:75` caption or the `03:127` "3 tables" count over the adjacent diagram is briefly misled. None + changes a finding, severity, count, or recommendation. **Low downstream risk.** +- **No correctness risk to the analysis conclusions:** the substance (the S2↔S3 back-edge, the FK-less + High item, the two god-units, the 4/5 verdict, the U1–U17 backlog) is internally consistent and + source-confirmed. +- **Residual oracle risk:** fan_in/fan_out (38/0, 34) were not independently re-derived (no index). If + an exact re-count is required, run `loomweave analyze .` and re-query — but these matched the prior + gate and are not load-bearing on any verdict. + +## Information Gaps + +- **loomweave index absent** → graph metrics (fan_in 38, fan_out 0, fan_out 34) accepted as stated, not + re-derived. Dependency *edges* were instead verified by direct source import-extraction (stronger + ground truth for the edge claims). +- **Per-document technical accuracy** (whether the patterns, severities, and recommendations are + *correct*) was **out of scope** for this gate and was covered by the upstream architecture-critic / + debt-cataloger passes. This gate checked integration, not re-adjudication. +- I did not re-verify every line-cite in `05`/`06`/`debt` into `src/` exhaustively; I targeted the + inter-document cites (into `02`) the task named, plus a sample of source cites. Intra-doc source + cites were spot-checked, not swept. + +## Caveats + +- This is a **structural / consistency** gate: contract conformance, cross-document agreement, + numeric/edge consistency, reference integrity. It does **not** re-judge subsystem boundaries, pattern + correctness, or finding completeness. +- All six fixes are **Low** and **non-blocking**. PASS-WITH-FIXES means: ship now, apply the hygiene + fixes opportunistically. They cluster into one mechanical pass: retarget 3 line-cites to the + re-numbered catalog, qualify 1 caption, correct 2 counts, disclose 1 elision. +- The fixes are deliberately *not* applied by this gate (validation ≠ authoring). Recommend the + coordinator apply them in `02`-citing docs (`05:252,262`; `debt:35`), `03` (`:75,:127`, L3 note), and + `06` (`:30`). diff --git a/docs/concepts/advisory-not-gating.md b/docs/concepts/advisory-not-gating.md index 97c2c13..084b59a 100644 --- a/docs/concepts/advisory-not-gating.md +++ b/docs/concepts/advisory-not-gating.md @@ -52,7 +52,7 @@ gate-like "all clear" into a tool that has no business issuing one. ## How this shapes what warpline feeds the federation warpline feeds advisory change-impact facts to governance-style surfaces (such as -Legis or a Charter layer): *what changed* and *what is downstream-affected*. Those +Legis or a Plainweave layer): *what changed* and *what is downstream-affected*. Those surfaces may have their own policy and their own gates — that is their authority. warpline supplies the facts; it never makes the call. The boundary is in [Federation](../federation.md). diff --git a/docs/federation.md b/docs/federation.md index f6e3aa9..95ddc0b 100644 --- a/docs/federation.md +++ b/docs/federation.md @@ -86,7 +86,7 @@ is `present`. ## What warpline feeds the federation (outbound) warpline feeds **advisory change-impact facts** to governance-style surfaces (Legis, -or a Charter layer): *what changed* and *what is downstream-affected*. Those +or a Plainweave layer): *what changed* and *what is downstream-affected*. Those surfaces may run their own policy and their own gates — that is their authority. warpline supplies the facts and never makes the call. diff --git a/docs/index.md b/docs/index.md index aa76593..47090c3 100644 --- a/docs/index.md +++ b/docs/index.md @@ -116,7 +116,7 @@ pairwise with its siblings, always enrich-only: - It **consumes** Loomweave SEI resolution and dated structural edges (the only proven, frozen inbound seam) and Filigree work-state links. - It **feeds** advisory change-impact facts to governance-style surfaces - (Legis/Charter): what changed and what is downstream-affected. It never decides + (Legis/Plainweave): what changed and what is downstream-affected. It never decides whether the change is allowed. - It **degrades honestly** when a sibling is absent — the answer reports `unavailable`, never an implied "clean" or "allowed" state. diff --git a/docs/integration/post-admission-consumer-tickets.md b/docs/integration/post-admission-consumer-tickets.md index 2b8f6c3..fc5a9fa 100644 --- a/docs/integration/post-admission-consumer-tickets.md +++ b/docs/integration/post-admission-consumer-tickets.md @@ -11,11 +11,11 @@ Do not patch sibling repos from Warpline delivery work. - Boundary: Loomweave owns current structure and SEI. Warpline supplies temporal history only. - Acceptance: Loomweave still answers current graph queries from Loomweave storage; Warpline absence disables only churn/recency enrichment. -## Charter +## Plainweave -- Goal: consume Warpline reverify/affected-set facts when Charter impact analysis lands. -- Boundary: Charter owns obligations, baselines, verification evidence, and requirement impact. Warpline supplies structural/temporal affected entities. -- Acceptance: Charter impact reports still run from local trace links when Warpline is absent. +- Goal: consume Warpline reverify/affected-set facts when Plainweave impact analysis lands. +- Boundary: Plainweave owns obligations, baselines, verification evidence, and requirement impact. Warpline supplies structural/temporal affected entities. +- Acceptance: Plainweave impact reports still run from local trace links when Warpline is absent. ## Legis diff --git a/docs/product/current-state.md b/docs/product/current-state.md index 8a0b72b..9766e74 100644 --- a/docs/product/current-state.md +++ b/docs/product/current-state.md @@ -1,72 +1,99 @@ # Current State - Warpline -Checkpoint: 2026-06-24 — `main` @ v1.2.0 (spine hardening shipped) +Checkpoint: 2026-06-26 — branch `plan/verification-freshness` (verification-freshness +BUILT but unreleased; not yet merged to `main`) ## The bet right now -**Rung 2 — verification-freshness** (PDR-0005): give warpline a `last_verified` axis, -sourced from its own gate result, so the reverify worklist answers *"changed since -last proven-good"* with a trust-decay signal — advisory, never gates. **Moves** the +**Rung 2 — verification-freshness** (PDR-0005): a `last_verified` axis sourced from +warpline's own gate result, so the reverify worklist answers *"changed since last +proven-good"* with a trust-decay signal — advisory, never gates. **Moves** the north-star from "reverify since HEAD~1" toward "since last proven-good." -Status: design **spec written, at the review gate** -(`docs/superpowers/specs/2026-06-23-verification-freshness-design.md`, on `main` since -the 1.2.0 merge). Next step: spec sign-off → `/axiom-planning` → build (same -subagent-driven flow as the hardening bet). Not yet filed as a tracker issue. +Status: **BUILT on `plan/verification-freshness`, unreleased.** Track B landed into the +branch across prior sessions — per-item `verification` block +(`fresh`/`stale`/`unverified`/`unavailable`) + a `verification_summary` rollup, a new +mutating verb `verify-record` / `warpline_verification_record` (the 2nd local-only +mutating tool), schema v4 (`verification_events`), and golden vector `GV-VF-1` — and is +recorded in `CHANGELOG.md` [Unreleased]. The frozen `warpline..v1` envelope +and the closed 6-key enrichment vocab are untouched (verification rides the +reverify-item schema). Sibling-sourced verification (wardline/filigree/legis) stays +honest-absent RESERVED. The merge + release to `main` is an owner escalation (see below). + +> **Reconciliation debt:** this build landed on the branch without an interim +> checkpoint, so there is no acceptance PDR for it yet. When the owner authorizes the +> merge/release, write a PDR-0006-style acceptance record (verdict, review basis, +> reversal trigger). ## Branch / release state -- **`main` = v1.2.0.** Three releases shipped this session (all owner-directed): - **v1.1.2** (post-commit hook hang fix), **v1.1.3** (version-metadata single-sourcing), - **v1.2.0** (spine hardening — correct-by-construction capture + honesty completeness - + 5th-producer conformance package), the last via a merge after a release-grade - multi-agent review (PDR-0006). -- `plan/spine-hardening` and `release/1.2.0` were deleted (fully merged into `main`). -- Install hygiene: single canonical warpline (uv tool **1.2.0**); the stale pre-rename - `heddle` editable venv was retired. +- **`main` = v1.2.0** (spine hardening; PDR-0006). +- **Working branch = `plan/verification-freshness`** — carries the built Track B plus + the 1.2.0 review-followup burndown (below). Unreleased. +- **Identity (standing requirement):** git/gh identity is **tachyon-beep** (active + account); johnm-dta is logged in but inactive. This session's commits used the + tachyon-beep email — verified before each commit. ## In flight (tracker) -Four review follow-ups (open, none blocking — from the 1.2.0 review): - -- `warpline-d7d04243b2` (P2 bug) — SKIPPED snapshot path (loomweave-absent) is - non-atomic and downgrades a usable prior snapshot (pre-existing R3-class). -- `warpline-fc09bdeddd` (P2 task) — contract fixtures + ENVELOPE_KEYS stale (missing - `enrichment_reasons`); **do with/before the hub handover**. -- `warpline-d88e223731` (P3 task) — promote `reason()` cause/fix invariant from - assert → ValueError (survive `python -O`). -- `warpline-17242c627b` (P3 task) — cover the atomic ROLLBACK branch + enforce the - no-open-transaction precondition. - -(Plus `warpline-3deba68a62` P4 "Future" placeholder.) The verification-freshness bet -lives in the spec, not yet the tracker. +The PDR-0006 release-grade-review follow-ups, reconciled against the tracker: + +- ✅ `warpline-d7d04243b2` (P2 bug) — SKIPPED snapshot non-atomic — **CLOSED** (prior + session; commit ddba775). +- ✅ `warpline-fc09bdeddd` (P2 task) — contract fixtures + ENVELOPE_KEYS missing + `enrichment_reasons` — **CLOSED this session** (commit 3f6f652). The fixture-drift + item meant to land "with the hub handover" is cleared. +- ✅ `warpline-d88e223731` (P3 task) — `reason()` cause/fix invariant assert→ValueError + (survive `python -O`) — **CLOSED this session** (commit 7683407, via an ultracode + multi-agent workflow). Also hardened `build_envelope` (hand-built-triple path) and + made `sei_reason` non-Optional. +- ⏳ `warpline-17242c627b` (P3 task) — cover the atomic ROLLBACK branch + enforce the + no-open-transaction precondition. **OPEN — clean, startable.** +- ⏳ `warpline-9eae3eb86a` (P3 task, filed 2026-06-24) — finish Charter→Plainweave in + the sibling guards + dated evidence (baseline refresh + re-grounding, not a sed). + **OPEN — gated** on the local `plainweave` sibling repo being present. +- `warpline-3deba68a62` (P4) — "Future" placeholder. + +Observation `warpline-obs-da4909ac64` (P3): `mcp.py` phantom_sort/phantom_knob guard +uses a bare `assert` (stripped under `-O`) — same class as d88e223731, different module; +scoped out and filed for separate triage (expires 2026-07-09 unless promoted). ## Open questions / blocked-on-owner (escalations) -1. **Deliver the 5th-producer handover to the federation hub** — GS-7 oracle wiring + - glossary freeze (OD-5 resolved-direction; warpline-side package done at - `docs/integration/2026-06-22-warpline-5th-producer-handover.md`). Outward-facing / - sibling — owner's call. The fixture-drift follow-up (`fc09bdeddd`) lands with it. -2. **(deferred)** Promoting `verification` into the frozen closed envelope vocab is a - future glossary/contract-evolution escalation (the v1 bet keeps it as a - reverify-item field — PDR-0005). - -*(Escalation #1 from the prior checkpoint — merge to `main` + cut 1.2.0 — is RESOLVED: -owner-directed and shipped this session.)* +1. **Deliver the 5th-producer handover to the federation hub** — outward-facing / + sibling, owner's call. warpline-side package is done + (`docs/integration/2026-06-22-warpline-5th-producer-handover.md`); GS-7 oracle wiring + + glossary freeze (OD-5 resolved-direction) remain. The fixture-drift follow-up + (`fc09bdeddd`) that was meant to land with it is now **CLOSED**, so the warpline-side + blockers are further reduced. +2. **Merge + release verification-freshness to `main`** — changing public release status + outside this repo is a grant escalation. The branch is built; the cutover (and its + acceptance PDR) is the owner's call. +3. **(deferred)** Promoting `verification` into the frozen closed envelope vocab is a + future glossary/contract-evolution escalation (v1 keeps it a reverify-item field — + PDR-0005). ## What this checkpoint did -- Recorded **PDR-0006** (accept + ship the hardening bet as v1.2.0 after a 14-agent - adversarially-verified review; verdict ship, 0 blockers/majors; defer the - verified-minor findings to tracked follow-ups). -- Roadmap: spine hardening moved out of Now (**shipped in v1.2.0**); verification- - freshness is the sole active Now bet. -- Metrics: 2026-06-24 reading for the 1.2.0 ship + review (all frozen invariants - re-verified; 338 passed; release gate green); 4 follow-ups tracked; no reversal - trigger crossed. +- Recorded this session's **execution** on the PDR-0006 follow-up punch-list: closed + `warpline-fc09bdeddd` (3f6f652) and `warpline-d88e223731` (7683407); filed observation + `warpline-obs-da4909ac64`. **No new PDR** — no product bet was decided, killed, or + reprioritized; this was repo-local acceptance of tracked quality debt, autonomous + under the `vision.md` grant. +- **Reconciled a stale workspace** — the prior brief (2026-06-24, `main` @ v1.2.0) + predated the verification-freshness build now on `plan/verification-freshness`; + current-state now reflects the branch, the built-but-unreleased bet, and the closed + follow-ups (with the reconciliation-debt flag above). +- **metrics.md** — 2026-06-26 reading: quality-debt burndown (3 of 4 original 1.2.0 + follow-ups closed); honesty guardrail strengthened (weft-reason invariant survives + `-O`); no reversal trigger crossed. +- **roadmap.md** — untouched (no horizon change; verification-freshness is still the Now + bet). ## Next session starts here -Pick up the **verification-freshness spec review** → `/axiom-planning` to generate the -implementation plan → build. Escalation #1 (hub handover) is waiting whenever the -owner wants to act on it. +Two clean pickups, owner's choice: (a) `warpline-17242c627b` (atomic ROLLBACK coverage ++ precondition guard) — the last ungated 1.2.0 follow-up; or (b) act on escalation #1/#2 +(hub handover, or merge+release verification-freshness — and write its acceptance PDR +then). `warpline-9eae3eb86a` stays blocked until the local `plainweave` sibling repo is +present. diff --git a/docs/product/federation-value-add-and-mcp-first-audit.md b/docs/product/federation-value-add-and-mcp-first-audit.md index a13e8e3..963b4bf 100644 --- a/docs/product/federation-value-add-and-mcp-first-audit.md +++ b/docs/product/federation-value-add-and-mcp-first-audit.md @@ -36,7 +36,7 @@ work-state, trust, requirements, or current-graph truth. | Work state, issues, observations, claims, lifecycle | Filigree | Read or propose links; never file, close, claim, or mutate work by default. | | Trust policy, findings, waivers, baselines, taint lattice | Wardline | Consume risk/finding facts as enrichment; never declare a change allowed or clean. | | Governance, signoff, CI/git attestations, overrides | Legis | Consume provenance and rename facts; emit advisory impact only; never govern. | -| Requirements, traceability, verification evidence, baselines | Charter | Consume obligation context; never emit requirement satisfaction or release readiness. | +| Requirements, traceability, verification evidence, baselines | Plainweave | Consume obligation context; never emit requirement satisfaction or release readiness. | | Demo corpus | Lacuna | Use as optional dogfood/showcase corpus; never as product authority. | | Change execution | Future Shuttle/Codeweave-style member | Do not bind until a real member exists. | @@ -475,7 +475,7 @@ Output `data`: "filigree": "available|unavailable|unknown", "wardline": "available|unavailable|unknown", "legis": "available|unavailable|unknown", - "charter": "available|unavailable|unknown" + "plainweave": "available|unavailable|unknown" } } ``` @@ -506,7 +506,7 @@ enrich-only. | Filigree + Warpline | Entity associations, issue/work status, reconciliation feed | `warpline.reverify_worklist.v1` candidate work context and optional `next_actions.filigree` | If Filigree is absent, Warpline omits work enrichment and never auto-files work. | | Wardline + Warpline | Finding/risk facts, suppression state, taint/trust-boundary context | `warpline.affected_scope.v1` for scoped scan hints | If Wardline is absent, Warpline says `risk=unavailable`; it never treats absence as clean. | | Legis + Warpline | `git_rename_list` / rename feed, branch/commit/PR/governance context | `warpline.preflight_impact.v1` advisory affected-set facts | If Legis is absent, Warpline uses raw git history and marks governance enrichment unavailable. | -| Charter + Warpline | Requirement links, verification freshness, baseline exposure | `warpline.obligation_impact_context.v1` advisory impacted obligations | If Charter is absent, Warpline omits requirement enrichment and never emits readiness verdicts. | +| Plainweave + Warpline | Requirement links, verification freshness, baseline exposure | `warpline.obligation_impact_context.v1` advisory impacted obligations | If Plainweave is absent, Warpline omits requirement enrichment and never emits readiness verdicts. | | Lacuna + Warpline | Seeded demo changes, real-member parity benchmark, and tour cases | Dogfood/demo results only | If Lacuna drifts, dogfood must either find another real code-change worklist or fail ready; synthetic cases are smoke coverage only. | Proposed payload names: @@ -518,7 +518,7 @@ Proposed payload names: - `warpline.preflight_impact.v1` - advisory impacted entities for governance context. - `warpline.obligation_impact_context.v1` - advisory impacted entities grouped by - Charter requirement ids. + Plainweave requirement ids. ### Endorsement Checklist @@ -654,32 +654,32 @@ Legis-side changes after admission: Priority: P1 for rename/provenance consumption; P2 for deeper preflight composition. -### Charter +### Plainweave -Charter enhances Warpline by supplying obligation context: requirements linked to +Plainweave enhances Warpline by supplying obligation context: requirements linked to changed or affected entities, verification freshness, stale evidence, baseline drift, and accepted trace links. -Warpline enhances Charter by supplying temporal affected sets that help Charter +Warpline enhances Plainweave by supplying temporal affected sets that help Plainweave answer "which obligations might this change touch?" without requiring a full manual trace walk. Warpline-side changes: -- Add optional Charter enrichment to `reverify`: requirement ids, verification +- Add optional Plainweave enrichment to `reverify`: requirement ids, verification freshness, baseline exposure, and obligation severity. - Add filters for `requirement_id`, `verification_state`, and `baseline_exposure`. -- Keep Charter facts as obligation context only; Warpline must not produce a +- Keep Plainweave facts as obligation context only; Warpline must not produce a release-readiness verdict. -Charter-side changes after admission: +Plainweave-side changes after admission: - Consume Warpline affected entities in requirement dossiers and impact analysis. - Include Warpline completeness/staleness in obligation impact reports. -- Preserve Charter as requirements and verification authority. +- Preserve Plainweave as requirements and verification authority. -Priority: P2 until Charter's federation adapters are live, then P1 because this +Priority: P2 until Plainweave's federation adapters are live, then P1 because this is a strong pair-mode story. ### Lacuna @@ -730,9 +730,9 @@ do not ship bindings to a non-member. Peer pattern observed: -- Loomweave and Charter favor namespaced object-action verbs: +- Loomweave and Plainweave favor namespaced object-action verbs: `entity_neighborhood_get`, `entity_recent_change_list`, - `charter_requirement_search`, `charter_baseline_list`. + `plainweave_requirement_search`, `plainweave_baseline_list`. - Legis favors domain-object verbs with action suffixes: `git_rename_list`, `policy_evaluate`, `signoff_status_get`, `identity_gap_list`. @@ -936,14 +936,14 @@ Acceptance: ### Slice 3 - Obligation-aware impact -Priority: P2 until Charter adapters are live, then P1. +Priority: P2 until Plainweave adapters are live, then P1. -Add Charter requirement and verification freshness enrichment to Warpline reverify +Add Plainweave requirement and verification freshness enrichment to Warpline reverify and affected-set outputs. Acceptance: -- Requirement ids and verification states appear only when Charter supplies them. +- Requirement ids and verification states appear only when Plainweave supplies them. - Warpline never emits release-readiness or requirement-satisfaction verdicts. ### Slice 4 - Demo and dogfood expansion diff --git a/docs/product/metrics.md b/docs/product/metrics.md index c5d4e8c..0d4f451 100644 --- a/docs/product/metrics.md +++ b/docs/product/metrics.md @@ -1,6 +1,6 @@ # Metrics - Warpline -Last read: 2026-06-24 (checkpoint) +Last read: 2026-06-26 (checkpoint) ## North-star @@ -24,7 +24,7 @@ Last read: 2026-06-24 (checkpoint) | Metric | Floor / ceiling | Current | Read on | |--------|-----------------|---------|---------| -| Member repo diff violations | 0 Warpline-caused diffs in Filigree, Wardline, Legis, Loomweave, or Charter | 0 beyond recorded baselines | 2026-06-13 | +| Member repo diff violations | 0 Warpline-caused diffs in Filigree, Wardline, Legis, Loomweave, or Plainweave | 0 beyond recorded baselines | 2026-06-13 | | Hook commit blocking | 0 nonzero hook exits in normal failure paths | `hook_ingest_exit_code` = 0 | 2026-06-13 | | Sibling absence crashes | 0 crashes when Loomweave is absent or enrichment is unavailable | Tests cover absent enrichment and `NO_SNAPSHOT`; malformed MCP and undecodable-file fixes added after review | 2026-06-13 | | Authority-boundary drift | 0 cases where Warpline owns current structure, obligations, work state, trust policy, or governance | Draft contracts and boundary tests pass | 2026-06-13 | @@ -68,6 +68,30 @@ adversarially-verified review (PDR-0006). No reversal trigger crossed. pre-rename `heddle` editable venv (which shadowed bare invocations at 1.0.0) was retired. +## 2026-06-26 readings — 1.2.0 follow-up burndown + +Execution session on the PDR-0006 deferred follow-ups (no bet change; no reversal +trigger crossed). + +- **Tracked quality debt** — 3 of the 4 original 1.2.0 review follow-ups now closed: + warpline-d7d04243b2 (prior session), and this session warpline-fc09bdeddd + (contract-fixture drift; commit 3f6f652) + warpline-d88e223731 (`reason()` + assert→ValueError; commit 7683407). Remaining: warpline-17242c627b (atomic ROLLBACK + coverage + precondition guard). New follow-up filed since: warpline-9eae3eb86a + (Charter→Plainweave evidence refresh, gated on the plainweave repo). +- **Authority-boundary / honesty guardrail — strengthened.** The weft-reason carrier + invariant (every non-clean reason carries cause+fix — the "unexplained absence" the + honesty doctrine forbids) now survives `python -O`: `reason()` and `build_envelope` + raise `ValueError` instead of relying on `-O`-strippable `assert`s, and `sei_reason` + is non-Optional. Verified by an independent `python -O` proof plus full suite green + (5 known env-only `PackageNotFoundError` failures, no 6th); mypy unchanged; ruff clean. +- **Observation filed** — warpline-obs-da4909ac64: the same bare-`assert`-under-`-O` + pattern remains in `mcp.py`'s inputSchema guard (different module; scoped out of + d88e223731). +- **No north-star or input-metric change** — no consumer-facing capability shipped this + session; verification-freshness remains built-but-unreleased on + `plan/verification-freshness`. + ## Reading notes - The north-star is deliberately agent-workflow based. Warpline wins only when an diff --git a/docs/product/prds/PRD-0001-agent-first-mcp-productization.md b/docs/product/prds/PRD-0001-agent-first-mcp-productization.md index 59f3352..d040f54 100644 --- a/docs/product/prds/PRD-0001-agent-first-mcp-productization.md +++ b/docs/product/prds/PRD-0001-agent-first-mcp-productization.md @@ -51,7 +51,7 @@ synthetic federation cases remain smoke coverage, not readiness evidence. the next P1 refactor. 4. FEDERATION BOUNDARY - Warpline responses identify absent, stale, skipped, or no-snapshot enrichment without claiming sibling-owned current truth. - Reject branch: any response that treats Loomweave, Charter, Legis, Wardline, + Reject branch: any response that treats Loomweave, Plainweave, Legis, Wardline, or Filigree data as Warpline-owned truth blocks acceptance. 5. SOLO MODE - With no sibling enrichment, Warpline still returns useful locator-keyed changed/timeline/reverify facts and explicit `NO_SNAPSHOT` or diff --git a/docs/product/vision.md b/docs/product/vision.md index 14f04b6..f67af26 100644 --- a/docs/product/vision.md +++ b/docs/product/vision.md @@ -18,7 +18,7 @@ shape of the codebase or the federation's operational systems of record. load, clearer reverify prompts, and cleaner post-admission integration seams. - Explicitly not: hosted analytics users, generic project-management users, teams that want Warpline to replace Loomweave, Filigree, Wardline, Legis, or - Charter. + Plainweave. ## Positioning @@ -41,7 +41,7 @@ Warpline owns temporal change-impact facts: Sibling authority boundaries are product doctrine, not implementation detail: - Loomweave owns current structure and SEI. -- Charter owns obligations, baselines, verification evidence, and requirement +- Plainweave owns obligations, baselines, verification evidence, and requirement impact. - Legis owns governance, sign-offs, CI/check context, and attestations. - Wardline owns trust policy, findings, baselines, waivers, judge labels, and diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 9d60033..1e67284 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -295,8 +295,8 @@ entities are added when a snapshot exists. ### `warpline capture-snapshot` -The only mutating query command: capture loomweave's dated edges into the local -store. Writes only `.weft/warpline/`. Schema `warpline.edge_snapshot.v1`. +Mutating: capture loomweave's dated edges into the local store. Writes only +`.weft/warpline/`. Schema `warpline.edge_snapshot.v1`. ```bash warpline capture-snapshot --repo /path/to/project --json @@ -312,6 +312,24 @@ warpline capture-snapshot --repo /path/to/project --commit HEAD~3 --json With loomweave absent, returns `completeness: SKIPPED` and `source_version: no_index` — an honest "no edges captured," not an error. +### `warpline verify-record` + +Record a local gate-pass verification event for a commit (advisory; warpline never gates). +Writes only `.weft/warpline/`. Schema `warpline.verification_record.v1`. Idempotent on +`(repo, commit, kind, source=warpline)`. + +```bash +warpline verify-record --repo /path/to/project --commit HEAD --kind test_pass --json +warpline verify-record --repo /path/to/project --commit HEAD --kind ci_pass --actor ci --json +``` + +| Flag | Default | Meaning | +| --- | --- | --- | +| `--commit REF` | (required) | The commit ref (resolved to object SHA before storage). | +| `--kind LABEL` | (required) | Free-form non-empty provenance label, e.g. `test_pass`, `ci_pass`, `gate_pass`. | +| `--actor ID` | — | Optional string identifying who recorded the event. | +| `--json` | off | Single-line JSON. | + --- ## Engineering / gate commands diff --git a/docs/reference/mcp-tools.md b/docs/reference/mcp-tools.md index 55109ed..2aa08d4 100644 --- a/docs/reference/mcp-tools.md +++ b/docs/reference/mcp-tools.md @@ -17,11 +17,13 @@ The server speaks line-delimited JSON-RPC on stdin/stdout and supports the | `tools/list` | List every tool with its `inputSchema`, `outputSchema`, and `metadata`. | | `tools/call` | Invoke a tool by `name` with `arguments`. | -## Six tools, twelve names +## Eight tools, sixteen names -There are **six** frozen federation tools. Each is registered under **two** names -— an endorsed name and a short shim — that return identical schema and data. So -`tools/list` reports twelve entries; they collapse to six tools. +There are **six** frozen federation contracts, plus `verify_record` +(verification-freshness) and `project_status` (a read-only binding/health probe) +— **eight** tools in all. Each is registered under **two** names — an endorsed +name and a short shim — that return identical schema and data. So `tools/list` +reports sixteen entries; they collapse to eight tools. | Endorsed name | Shim | Schema | Mutating? | | --- | --- | --- | --- | @@ -31,10 +33,17 @@ There are **six** frozen federation tools. Each is registered under **two** name | `warpline_impact_radius_get` | `blast_radius` | `warpline.impact_radius.v1` | no | | `warpline_reverify_worklist_get` | `reverify` | `warpline.reverify_worklist.v1` | no | | `warpline_edge_snapshot_capture` | `capture_snapshot` | `warpline.edge_snapshot.v1` | yes (local only) | - -All tools require `repo` (a path string). The read tools are marked -`read_only: true` but may initialize `.weft/warpline/` state on first touch; only -`warpline_edge_snapshot_capture` records new facts. +| `warpline_verification_record` | `verify_record` | `warpline.verification_record.v1` | yes (local only) | +| `warpline_project_status_get` | `project_status` | `warpline.project_status.v1` | no (read-only) | + +All tools require `repo` (a path string). Most read tools are marked +`read_only: true` but may initialize `.weft/warpline/` state on first touch (they +open the store, which creates/migrates it). The exception is +`warpline_project_status_get`: it is the one **genuinely** read-only tool — +`writes_local_state: false`, `mutates_paths: []` — and reports an absent store as +absent rather than initializing it. `warpline_edge_snapshot_capture` and +`warpline_verification_record` are the two mutating tools — both write only to +`.weft/warpline/`. ## The success envelope @@ -371,3 +380,97 @@ With loomweave absent, `completeness` is `SKIPPED` and `source_version` is capture (listed in `failed_entities`); the snapshot is usable but a floor. `enrichment.sei` is `unavailable` when loomweave was unreachable (the SEI authority could not be consulted), else `absent`. + +--- + +## `warpline_verification_record` / `verify_record` + +`warpline.verification_record.v1` — **2nd mutating tool**. Records a gate-pass +verification event for a commit into `.weft/warpline/`. Never mutates a sibling repo. +Advisory; warpline never gates. Idempotent on `(repo, commit, kind, source=warpline)`. + +**Input** + +| Field | Type | Notes | +| --- | --- | --- | +| `repo` | string | (required) | +| `commit` | string | (required) commit ref — resolved to object SHA before storage; symbolic refs are never persisted. | +| `kind` | string | (required) free-form non-empty provenance label, e.g. `test_pass`, `ci_pass`, `gate_pass`. | +| `actor` | string \| null | optional — who recorded the event. | + +**`data`** + +```json +{ + "commit_sha": "...", + "kind": "test_pass", + "verified_at": "2026-06-25T10:00:00+00:00", + "actor": "ci", + "source": "warpline", + "idempotency": "recorded | already_recorded" +} +``` + +`idempotency: already_recorded` means the row already existed (a second call for +the same `(repo, commit, kind)` tuple is a no-op — exactly one row is stored). +All enrichment keys are at their default state (`sei: absent`, `edges: absent`, +`work: unavailable`, `risk: unavailable`, `governance: unavailable`, +`requirements: unavailable`) — no graph-layer dependency. + +## `warpline_project_status_get` / `project_status` + +`warpline.project_status.v1` — **read-only binding/health probe**. Reports +whether THIS warpline build can read and **serve** the snapshot store for `repo` +(`data.binding_ok`). warpline is repo-per-call — bound to nothing at launch — so +this is a *can-service-R* check, not a launch-time binding: given `repo=R` the +server reads the schema version **from inside** R's snapshot store +(`data.store.schema_version`, `null` when absent/unreadable), so a stale binary +that cannot read its store is caught — unlike mere directory existence, which +such a binary would still see. + +Strictly read-only: it is the one tool with `writes_local_state: false` / +`mutates_paths: []`. It creates and migrates no snapshot state — an absent store +reports absent (no DB is created; with a `capture_snapshot` next-action), and a +present store's `warpline.db` is left byte-for-byte unchanged. (Opening a present +WAL-mode store read-only may spawn gitignored `-wal`/`-shm` SQLite coordination +sidecars — these are not snapshot state; `mode=ro` is chosen over `immutable=1` +so the probe always reads the latest committed schema version.) + +**Input** + +| Field | Type | Notes | +| --- | --- | --- | +| `repo` | string | (required) — absolute path of the repo to probe. | + +**`data`** + +```json +{ + "resolved_root": "/abs/path/to/repo", + "store": { + "present": true, + "readable": true, + "schema_version": 4, + "snapshot_rev": "c0ffee", + "change_event_count": 2221 + }, + "store_status": "ok", + "binding_ok": true +} +``` + +`binding_ok` is true **iff** the store is present, readable, and at a schema +version this build serves. The three not-bound outcomes (`store_status` is a +closed vocab: `ok` | `store_absent` | `store_unreadable` | `schema_ahead`): + +| Outcome | `store_status` | `store.present` | `store.readable` | `store.schema_version` | `binding_ok` | +| --- | --- | --- | --- | --- | --- | +| Store readable + serveable | `ok` | `true` | `true` | `` | `true` | +| Never `capture_snapshot`-ed | `store_absent` | `false` | `false` | `null` | `false` | +| Corrupt / unparseable store | `store_unreadable` | `true` | `false` | `null` | `false` | +| Written by a newer build (schema beyond this binary — the stale-binary case) | `schema_ahead` | `true` | `false` | `null` | `false` | + +When not bound, the human-readable reason rides on `warnings[0]` (e.g. naming the +on-disk schema vs the highest this build serves). Federation consumers (e.g. +Lacuna's MCP-attachment harness) assert on `data.binding_ok` plus +`data.store.schema_version is not null` — the non-tautological store-read signal. diff --git a/docs/superpowers/plans/2026-06-25-verification-freshness.md b/docs/superpowers/plans/2026-06-25-verification-freshness.md new file mode 100644 index 0000000..c54adec --- /dev/null +++ b/docs/superpowers/plans/2026-06-25-verification-freshness.md @@ -0,0 +1,2152 @@ +# Verification-Freshness (Rung 2, Track B) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Give warpline a `last_verified` axis sourced from its own gate result, so the reverify worklist surfaces an honest per-item `fresh / stale / unverified / unavailable` verification state plus a trust-decay signal — advisory, enrich-only, never gating. + +**Architecture:** A new `verification_events` table (schema v4) records "gate K passed as-of commit C" (one row per run, mirroring `change_events`). A new mutating verb `verify-record` (CLI + MCP, the 2nd mutating tool) writes those rows. A pure `compose_verification_freshness` function (mirroring `_enrichment.py`) computes per-entity freshness by git reachability (`covers(verified_commit, change_commit)`). The reverify command attaches a `verification` block to each worklist item plus a `verification_summary` rollup to the `data` block, advisory-sorts stale-of-trust first, and **never filters**. + +**Tech Stack:** Python 3 (stdlib `sqlite3`, `subprocess` for git), the existing warpline store/envelope/listing modules, argparse CLI, hand-rolled JSON-RPC MCP server. No new dependencies. + +## Global Constraints + +These bind **every** task. Copied from the spec and the frozen contract: + +- **Enrich-only, never gates.** Verification annotates and may re-sort the worklist; it MUST NEVER remove/filter an item. (Hard anti-goal.) +- **`meta.local_only: true` and `meta.peer_side_effects: []`** on every envelope — preserved, never weakened. +- **The frozen closed enrichment vocab is exactly 6 keys** (`sei`, `edges`, `work`, `risk`, `governance`, `requirements`). Verification is **NOT** added to it. It rides as a reverify-worklist-item field and a `data`-block summary. `build_envelope` raises `ValueError` if you put any other key into `enrichment`/`enrichment_reasons` — so verification MUST NOT appear there. +- **The canonical 11 `reason_class` values are frozen** (`clean`, `disabled`, `unresolved_input`, `rejected`, `dead_path`, `unreachable`, `misrouted`, `error`, `scheme_mismatch`, `stale`, `partial`). Reuse them — add NO new class. (Mapping below.) +- **Every non-`fresh`/non-clean state carries a weft-reason triple** `{reason_class, cause, fix}` via `listing.reason()`. Absence is always EXPLAINED, never a bare scalar and never read as verified. +- **The frozen `entity` view is `{locator, sei}` only** (`refs.py:entity_view`). Do NOT add fields to it; thread `entity_key_id` separately. +- **Commit SHAs are stored resolved.** Never store a symbolic ref (`HEAD`) — always resolve to an object SHA first (the Plan A lesson). +- **Migrations use `conn.execute()` only**, never `executescript()` (which implicit-commits and breaks the runner's `BEGIN IMMEDIATE` atomicity). All columns/tables NULLable-friendly and additive. +- **Gates that must stay green:** `uv run ruff check .`, `uv run mypy src/warpline`, `uv run pytest tests -v`, `uv run warpline dogfood-eval`, `uv run warpline mcp-smoke`, and the member-diff guard. The mutating tool must appear in `tools/list` with correct metadata. +- **Version:** this is an additive **minor** (`1.3.0`) — new tool + new reverify-item field, frozen contracts untouched. The CHANGELOG gets an `[Unreleased]`/`1.3.0` entry; **the actual tag/release is owner-reserved and out of scope for this plan** (stop at merge-ready). + +--- + +## File Structure + +| File | Responsibility | Action | +|------|----------------|--------| +| `src/warpline/store.py` | v4 `verification_events` table: migration, presence-floor, accessors | Modify | +| `src/warpline/git.py` | `is_ancestor` / `commits_between` / `resolve_commit` reachability helpers | Modify | +| `src/warpline/verification.py` | Pure `compose_verification_freshness` + reason mapping | **Create** | +| `src/warpline/commands.py` | `verify_record` verb; reverify integration (verification_index, summary, advisory sort); `SCHEMA_VERIFICATION_RECORD` | Modify | +| `src/warpline/cli.py` | `verify-record` subcommand wiring | Modify | +| `src/warpline/mcp.py` | `warpline_verification_record` tool spec + handler + consumes map | Modify | +| `src/warpline/reverify.py` | Thread per-item `verification` block into rendered items | Modify | +| `tests/test_verification_store.py` | v4 migration + accessor tests | **Create** | +| `tests/test_git_reachability.py` | `is_ancestor`/`commits_between`/`resolve_commit` tests | **Create** | +| `tests/test_verification_compose.py` | Pure freshness unit tests (vectors-first) | **Create** | +| `tests/test_verify_record.py` | verb + MCP tool + error tests | **Create** | +| `tests/test_reverify_verification.py` | reverify integration tests (block, summary, never-filter, sort) | **Create** | +| `tests/contracts/test_golden_vectors.py` | `GV-VF-1` golden vector | Modify | +| `tests/fixtures/contracts/warpline/golden-vectors.json` | `GV-VF-1` fixture entry | Modify | +| `docs/reference/cli.md`, `docs/reference/mcp-tools.md` | document `verify-record` / `warpline_verification_record` | Modify | +| `CHANGELOG.md` | `[Unreleased]` / 1.3.0 entry | Modify | + +--- + +## Reference: existing patterns (read before starting) + +- **Migration machinery** — `store.py`: `Migration` NamedTuple (`store.py:123-133`), `MIGRATIONS` list (`store.py:204-207`), `HIGHEST_KNOWN_VERSION = max(... )` (`store.py:213`), `_run_migrations` runner (`store.py:324-442`, opens `BEGIN IMMEDIATE`, calls `migration.apply(conn)`, bumps `PRAGMA user_version` + `meta`), `_schema_presence_floor` (`store.py:286-321`), `_table_exists` (`store.py:268-273`), v3 migration `_migrate_v3_co_change_pairs` (`store.py:163-194`). +- **`change_events` template** — DDL `store.py:68-80`; insert `append_change_event` (`store.py:881-924`, `INSERT OR IGNORE` + `self.conn.commit()`); query `list_change_events` (`store.py:944-1003`, JOINs `entity_keys`, returns `list[dict]` with `entity_key_id`, `commit_sha`, `changed_at`). `_repo_id` = sha256 of resolved path (`store.py:490-491`); `ensure_repo(repo) -> str` (`store.py:493-501`). +- **Mutating verb template** — `capture_snapshot` (`commands.py:994-1149`); schema consts (`commands.py:44-50`); `build_envelope` (`envelope.py:61-99`) which always injects `enrichment_reasons.requirements` and validates the closed vocab; `local_only_meta` (`envelope.py:44-58`). +- **CLI wiring** — `capture-snapshot` subparser (`cli.py:334-339`) + dispatch (`cli.py:525-532`). +- **MCP wiring** — `_tool_spec` for `warpline_edge_snapshot_capture` (`mcp.py:217-242`); `_metadata` helper (`mcp.py:39-57`); `_HANDLERS` zip (`mcp.py:436-443`); `_h_capture` (`mcp.py:423-433`); `_HANDLER_CONSUMES` (`mcp.py:510-521`); `WarplineError → _error` conversion (`mcp.py:653-661`). +- **Errors** — `ERROR_CODES` frozen set (`errors.py:8-23`, includes `invalid_rev_range`, `invalid_entity_ref`); `WarplineError` base (`errors.py:26-67`); `BadRevisionError(code="invalid_rev_range")` (`errors.py:83-87`). +- **Pure enrichment** — `_enrichment.py` whole file (imports only `typing.Any` + `listing.reason`; no store/git/IO). `listing.reason(reason_class, *, cause, fix)` (`listing.py:34-44`); `REASON_CLASSES` (`listing.py:17-31`). +- **Reverify** — `render_reverify_worklist` (`reverify.py:19-79`, builds per-item dict, returns `(items, work_seen, candidates)`); `reverify_worklist` command (`commands.py:745-876`, pipeline: `compute_blast_radius` → `enrich_blast` → `render_reverify_worklist` → `apply_filters` → `apply_sort` → federation → `apply_overflow` → `apply_page` → `build_envelope`); `enrich_blast` (`_blast.py:123-157`, **drops `entity_key_id`** — the entity view is `{locator, sei}`). +- **Git reachability template** — `_commits_behind` (`propagation.py:19-32`, `git rev-list --count A..HEAD`); generic runners `_git`/`_git_optional` (`git.py:14-21`, `74-82`). +- **Golden vectors** — test `tests/contracts/test_golden_vectors.py` (e.g. `test_gv_hon_sei_*` ~line 410); fixture index `tests/fixtures/contracts/warpline/golden-vectors.json`. Helpers `_git_repo`, `_store`, `_seed_entity`, `_add_change` live in that test module — reuse them. + +--- + +## Task 1: v4 `verification_events` schema + accessors (store) + +**Files:** +- Modify: `src/warpline/store.py` (SCHEMA base block; `MIGRATIONS` list; `_schema_presence_floor`; new accessor methods) +- Test: `tests/test_verification_store.py` (create) + +**Interfaces:** +- Consumes: existing `WarplineStore.open(path)`, `ensure_repo(repo) -> str`, `_repo_id(repo) -> str`, `default_store_path(repo)`. +- Produces: + - `WarplineStore.record_verification_event(*, repo_id: str, commit_sha: str, kind: str, verified_at: str, actor: str | None, source: str = "warpline") -> bool` — `INSERT OR IGNORE` (idempotent on the UNIQUE key), commits; returns `True` if a new row was inserted, `False` if it already existed. + - `WarplineStore.list_verification_events(repo: Path) -> list[dict[str, object]]` — all rows for the repo, ordered **oldest-first by the normalized `verified_at` instant** then `id` (NOT a raw lexical sort — see Step 5; a caller-supplied non-UTC offset must still sort chronologically). Each dict has keys `commit_sha`, `kind`, `verified_at`, `actor`, `source`. + - `WarplineStore.list_change_events_for_key_ids(repo: Path, key_ids: list[int]) -> list[dict[str, object]]` — change events filtered to the given entity key ids (empty list → `[]`). Avoids the full-table scan when reverify only needs the worklist's entities. + - Schema is at version **4**; presence-floor recognises `verification_events`. (This also requires updating the existing `tests/test_store_migrations.py` pin — Step 6b.) + +- [ ] **Step 1: Write the failing migration + accessor tests** + +Create `tests/test_verification_store.py`: + +```python +from __future__ import annotations + +import sqlite3 +import subprocess +from pathlib import Path + +from warpline.store import HIGHEST_KNOWN_VERSION, WarplineStore, default_store_path + + +def _open(tmp_path: Path) -> WarplineStore: + return WarplineStore.open(default_store_path(tmp_path)) + + +def test_schema_reaches_version_4(tmp_path: Path) -> None: + with _open(tmp_path) as store: + version = store.conn.execute("PRAGMA user_version").fetchone()[0] + assert int(version) == 4 + assert HIGHEST_KNOWN_VERSION == 4 + + +def test_verification_events_table_exists(tmp_path: Path) -> None: + with _open(tmp_path) as store: + row = store.conn.execute( + "SELECT 1 FROM sqlite_master WHERE type='table' AND name='verification_events'" + ).fetchone() + assert row is not None + + +def test_reopen_is_idempotent(tmp_path: Path) -> None: + path = default_store_path(tmp_path) + with WarplineStore.open(path) as store: + store.conn.execute("PRAGMA user_version").fetchone() + # Re-open: no migration re-runs, no error, still v4. + with WarplineStore.open(path) as store: + assert int(store.conn.execute("PRAGMA user_version").fetchone()[0]) == 4 + + +def test_presence_floor_recovers_dropped_table(tmp_path: Path) -> None: + path = default_store_path(tmp_path) + with WarplineStore.open(path) as store: + pass + # Simulate a v4 marker whose table is missing on disk: drop it and lie in meta. + raw = sqlite3.connect(path) + raw.execute("DROP TABLE verification_events") + raw.commit() + raw.close() + # Re-open: presence-floor must detect the missing table and re-run v4. + with WarplineStore.open(path) as store: + row = store.conn.execute( + "SELECT 1 FROM sqlite_master WHERE type='table' AND name='verification_events'" + ).fetchone() + assert row is not None + + +def test_record_and_list_round_trip(tmp_path: Path) -> None: + with _open(tmp_path) as store: + repo_id = store.ensure_repo(tmp_path) + store.record_verification_event( + repo_id=repo_id, + commit_sha="a" * 40, + kind="test_pass", + verified_at="2026-06-25T10:00:00+00:00", + actor="ci-bot", + source="warpline", + ) + events = store.list_verification_events(tmp_path) + assert len(events) == 1 + assert events[0]["commit_sha"] == "a" * 40 + assert events[0]["kind"] == "test_pass" + assert events[0]["actor"] == "ci-bot" + assert events[0]["source"] == "warpline" + + +def test_record_is_idempotent_on_unique_key(tmp_path: Path) -> None: + with _open(tmp_path) as store: + repo_id = store.ensure_repo(tmp_path) + for _ in range(2): + store.record_verification_event( + repo_id=repo_id, + commit_sha="b" * 40, + kind="test_pass", + verified_at="2026-06-25T10:00:00+00:00", + actor="ci-bot", + source="warpline", + ) + assert len(store.list_verification_events(tmp_path)) == 1 + + +def test_list_orders_by_verified_at(tmp_path: Path) -> None: + with _open(tmp_path) as store: + repo_id = store.ensure_repo(tmp_path) + store.record_verification_event( + repo_id=repo_id, commit_sha="c" * 40, kind="test_pass", + verified_at="2026-06-25T12:00:00+00:00", actor=None, source="warpline", + ) + store.record_verification_event( + repo_id=repo_id, commit_sha="d" * 40, kind="test_pass", + verified_at="2026-06-25T09:00:00+00:00", actor=None, source="warpline", + ) + events = store.list_verification_events(tmp_path) + assert [e["commit_sha"] for e in events] == ["d" * 40, "c" * 40] + + +def test_list_orders_chronologically_across_offsets(tmp_path: Path) -> None: + # A chronologically-LATER value with a non-UTC offset must NOT sort before an + # earlier UTC value. 14:00-04:00 == 18:00Z is later than 17:00+00:00. + with _open(tmp_path) as store: + repo_id = store.ensure_repo(tmp_path) + store.record_verification_event( + repo_id=repo_id, commit_sha="e" * 40, kind="test_pass", + verified_at="2026-06-25T17:00:00+00:00", actor=None, source="warpline", + ) + store.record_verification_event( + repo_id=repo_id, commit_sha="f" * 40, kind="test_pass", + verified_at="2026-06-25T14:00:00-04:00", actor=None, source="warpline", + ) + events = store.list_verification_events(tmp_path) + # UTC 17:00 (e) is earlier than UTC 18:00 (f) -> e first. + assert [ev["commit_sha"] for ev in events] == ["e" * 40, "f" * 40] + + +def test_list_change_events_for_key_ids_filters(tmp_path: Path) -> None: + with _open(tmp_path) as store: + repo_id = store.ensure_repo(tmp_path) + k1 = store.ensure_entity_key(repo_id, "python:function:m.py::f", None, "1" * 40) + k2 = store.ensure_entity_key(repo_id, "python:function:m.py::g", None, "2" * 40) + for kid, sha in ((k1, "1" * 40), (k2, "2" * 40)): + store.append_change_event( + repo_id=repo_id, entity_key_id=kid, commit_sha=sha, path="m.py", + change_kind="modified", actor="dev", changed_at="2026-06-25T08:00:00+00:00", + ) + only_k1 = store.list_change_events_for_key_ids(tmp_path, [k1]) + assert {r["entity_key_id"] for r in only_k1} == {k1} + assert store.list_change_events_for_key_ids(tmp_path, []) == [] + + +def test_list_change_events_for_key_ids_is_oldest_first(tmp_path: Path) -> None: + # Ordering is load-bearing: compose_verification_freshness treats + # entity_change_commits[-1] as the LATEST change. A wrong ORDER BY would make + # the OLDEST change the "latest" and silently report stale-as-fresh. + with _open(tmp_path) as store: + repo_id = store.ensure_repo(tmp_path) + k = store.ensure_entity_key(repo_id, "python:function:m.py::f", None, "1" * 40) + store.append_change_event( + repo_id=repo_id, entity_key_id=k, commit_sha="1" * 40, path="m.py", + change_kind="modified", actor="dev", changed_at="2026-06-25T08:00:00+00:00", + ) + store.append_change_event( + repo_id=repo_id, entity_key_id=k, commit_sha="2" * 40, path="n.py", + change_kind="modified", actor="dev", changed_at="2026-06-25T20:00:00+00:00", + ) + rows = store.list_change_events_for_key_ids(tmp_path, [k]) + assert [r["commit_sha"] for r in rows] == ["1" * 40, "2" * 40] # oldest-first +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `uv run pytest tests/test_verification_store.py -v` +Expected: FAIL — `HIGHEST_KNOWN_VERSION == 3` (version assert fails) and `record_verification_event` / `list_verification_events` do not exist (`AttributeError`). + +- [ ] **Step 3: Add the v4 migration function** + +In `src/warpline/store.py`, immediately after `_migrate_v3_co_change_pairs` (ends ~`store.py:194`), add: + +```python +def _migrate_v4_verification_events(conn: sqlite3.Connection) -> None: + """v4 (Rung 2 Track B): verification-freshness events. + + ``verification_events`` records a per-commit gate-pass fact ("gate ``kind`` + passed as-of commit ``commit_sha``"), one row per run — mirroring + ``change_events``. Freshness is computed at read time by git reachability + (is a change commit an ancestor-or-equal of a verified commit), never by + stamping every entity. Warpline OWNS this fact (its own gate result); it + mirrors no sibling. ``commit_sha`` is always a resolved object SHA, never a + symbolic ref. The UNIQUE key makes a re-record of the same (repo, commit, + kind, source) idempotent. + """ + + conn.execute( + """ + CREATE TABLE IF NOT EXISTS verification_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + repo_id TEXT NOT NULL, + commit_sha TEXT NOT NULL, + kind TEXT NOT NULL, + verified_at TEXT NOT NULL, + actor TEXT, + source TEXT NOT NULL DEFAULT 'warpline', + UNIQUE(repo_id, commit_sha, kind, source) + ) + """ + ) +``` + +- [ ] **Step 4: Register the migration and bump the presence-floor** + +In `src/warpline/store.py`, extend the `MIGRATIONS` list (`store.py:204-207`): + +```python +MIGRATIONS: list[Migration] = [ + Migration(version=2, apply=_migrate_v2_anchor_columns), + Migration(version=3, apply=_migrate_v3_co_change_pairs), + Migration(version=4, apply=_migrate_v4_verification_events), +] +``` + +(`HIGHEST_KNOWN_VERSION` at `store.py:213` is computed from this list — it becomes 4 automatically.) + +In `_schema_presence_floor` (`store.py:286-321`), after the v3 check block (`if claimed >= 3: ... floor = 3`) and before the final `return claimed`, add: + +```python + # v4 (Rung 2 Track B): the verification_events table. + if claimed >= 4: + if not _table_exists(conn, "verification_events"): + return floor + floor = 4 +``` + +- [ ] **Step 5: Add the accessors** + +In `src/warpline/store.py`, alongside the other `change_events` accessors (after `list_change_events`, ~`store.py:1003`), add two methods to the `WarplineStore` class: + +```python + def record_verification_event( + self, + *, + repo_id: str, + commit_sha: str, + kind: str, + verified_at: str, + actor: str | None, + source: str = "warpline", + ) -> bool: + """Record one gate-pass fact. Idempotent on (repo, commit, kind, source). + + Returns True if a NEW row was inserted, False if an identical event + already existed (the ``INSERT OR IGNORE`` was a no-op). This gives the + verb an O(1), race-free idempotency signal without a second table scan. + ``commit_sha`` must be a resolved object SHA (the caller resolves the ref). + """ + + cursor = self.conn.execute( + """ + INSERT OR IGNORE INTO verification_events( + repo_id, commit_sha, kind, verified_at, actor, source + ) VALUES (?, ?, ?, ?, ?, ?) + """, + (repo_id, commit_sha, kind, verified_at, actor, source), + ) + inserted = cursor.rowcount > 0 + self.conn.commit() + return inserted + + def list_verification_events(self, repo: Path) -> list[dict[str, object]]: + """All verification events for ``repo``, ordered oldest-first by verified_at. + + ``verified_at`` is ISO-8601 written by the verb. We do NOT lexical-sort: + a caller-supplied ``now`` could carry a non-UTC offset, and a + chronologically-later ``...-04:00`` value sorts lexically BEFORE a UTC + ``...+00:00`` one — which would corrupt ``compose_verification_freshness``'s + most-recent-covering-event identification. So we normalize to the UTC + instant with ``datetime()`` (mirroring ``list_change_events`` at + ``store.py:~999``), and COALESCE back to the raw string so a value + ``datetime()`` cannot parse still sorts deterministically by its lexical + form rather than vanishing. ``id`` is the final tiebreak. + """ + + repo_id = self._repo_id(repo) + rows = self.conn.execute( + """ + SELECT commit_sha, kind, verified_at, actor, source + FROM verification_events + WHERE repo_id = ? + ORDER BY COALESCE(datetime(verified_at), verified_at), id + """, + (repo_id,), + ).fetchall() + return [dict(row) for row in rows] + + def list_change_events_for_key_ids( + self, repo: Path, key_ids: list[int] + ) -> list[dict[str, object]]: + """Change events filtered to ``key_ids`` (reverify's verification path). + + Pushes the entity filter into SQL (``WHERE ce.entity_key_id IN (...)``) + so reverify does not full-table-scan every change event in the repo just + to group commits by the handful of entities in the worklist. Empty + ``key_ids`` short-circuits to ``[]``. Returns the same row shape as + ``list_change_events`` (carries ``entity_key_id``, ``commit_sha``, + ``changed_at``), ordered oldest-first by the normalized ``changed_at`` + instant then ``id`` so callers can take the latest change as the last row. + """ + + if not key_ids: + return [] + repo_id = self._repo_id(repo) + placeholders = ",".join("?" for _ in key_ids) + rows = self.conn.execute( + f""" + SELECT ce.commit_sha, ce.changed_at, ce.entity_key_id + FROM change_events ce + WHERE ce.repo_id = ? + AND ce.entity_key_id IN ({placeholders}) + ORDER BY COALESCE(datetime(ce.changed_at), ce.changed_at), ce.id + """, + (repo_id, *sorted(set(key_ids))), + ).fetchall() + return [dict(row) for row in rows] +``` + +- [ ] **Step 6: Run the new tests to verify they pass** + +Run: `uv run pytest tests/test_verification_store.py -v` +Expected: PASS (11 tests). + +- [ ] **Step 6b: Update the existing migration-version pin (REQUIRED — this hard-fails otherwise)** + +`tests/test_store_migrations.py:48` currently reads `assert store_mod.HIGHEST_KNOWN_VERSION == 3`. Bumping the schema to v4 makes it fail. Update the assertion AND every stale-v3 prose/comment site the reviewer located (grep first to confirm current wording — `grep -n "HIGHEST_KNOWN_VERSION\|== 3\|v3\|co_change\|schema_version" tests/test_store_migrations.py`): + +```python +# tests/test_store_migrations.py — change the version assertion (was == 3) + assert store_mod.HIGHEST_KNOWN_VERSION == 4 +``` + +Sites to update (verified by the plan-review reality pass): +- **line 48** — `assert ... == 3` → `== 4` (the hard pin). +- **lines 45-46** — comment "highest known version is 3" → name v4. +- **line 131** — prose "lands at v3" → "lands at v4". +- **lines 166-169** — docstring "meta.schema_version=3" → 4. +- **line 175** — "co_change_pairs (v3)" → mention `verification_events` (v4) as the new highest. + +Do NOT touch the DYNAMIC refs (lines 44, 47, 82, 96, 187, 198 — they read `HIGHEST_KNOWN_VERSION`/`MIGRATIONS` programmatically) or the monkeypatch synthetic-migration block (lines 217-288 — it sets `HIGHEST_KNOWN_VERSION` explicitly and is inert to the real bump). After editing, run the whole file and treat ANY remaining comment/assertion mismatch as required to fix, not optional polish. If the file also enumerates an expected-tables set, add `verification_events`. + +- [ ] **Step 7: Run the full store-affecting suite + types** + +Run: `uv run pytest tests -k "store or migration or schema" -v && uv run mypy src/warpline` +Expected: PASS (including the updated `test_store_migrations.py` pin), no type errors. (Confirms the new migration didn't regress existing schema tests.) + +- [ ] **Step 8: Commit** + +```bash +git add src/warpline/store.py tests/test_verification_store.py tests/test_store_migrations.py +git commit -m "feat(store): v4 verification_events table + key-id-filtered change accessor" +``` + +--- + +## Task 2: git reachability helpers + +**Files:** +- Modify: `src/warpline/git.py` (add three helpers) +- Test: `tests/test_git_reachability.py` (create) + +**Interfaces:** +- Consumes: existing `_git_optional` pattern in `git.py`. +- Produces (all in `src/warpline/git.py`, module-level functions): + - `resolve_commit(repo: Path, ref: str) -> str | None` — resolve a ref to a 40-hex object SHA via `git rev-parse --verify ^{commit}`; `None` if unresolvable (never raises). + - `is_ancestor(repo: Path, ancestor: str, descendant: str) -> bool | None` — `git merge-base --is-ancestor`: `True` (rc 0), `False` (rc 1), `None` for any other rc (bad/missing commit, shallow clone — "could not compute"). + - `commits_between(repo: Path, ancestor: str, descendant: str) -> int | None` — `git rev-list --count ancestor..descendant`; `None` on failure. + +- [ ] **Step 1: Write the failing tests** + +Create `tests/test_git_reachability.py`: + +```python +from __future__ import annotations + +import subprocess +from pathlib import Path + +from warpline.git import commits_between, is_ancestor, resolve_commit + + +def _run(repo: Path, *args: str) -> str: + return subprocess.run( + ["git", *args], cwd=repo, check=True, text=True, capture_output=True + ).stdout.strip() + + +def _repo_with_three_commits(tmp_path: Path) -> tuple[Path, list[str]]: + repo = tmp_path / "r" + repo.mkdir() + _run(repo, "init", "-q") + _run(repo, "config", "user.email", "t@t") + _run(repo, "config", "user.name", "t") + shas: list[str] = [] + for i in range(3): + (repo / "f.txt").write_text(f"v{i}\n") + _run(repo, "add", ".") + _run(repo, "commit", "-q", "-m", f"c{i}") + shas.append(_run(repo, "rev-parse", "HEAD")) + return repo, shas + + +def test_resolve_commit_resolves_head_to_object_sha(tmp_path: Path) -> None: + repo, shas = _repo_with_three_commits(tmp_path) + resolved = resolve_commit(repo, "HEAD") + assert resolved == shas[2] + assert len(resolved) == 40 + + +def test_resolve_commit_returns_none_for_bad_ref(tmp_path: Path) -> None: + repo, _ = _repo_with_three_commits(tmp_path) + assert resolve_commit(repo, "no-such-ref") is None + + +def test_is_ancestor_true_for_earlier_commit(tmp_path: Path) -> None: + repo, shas = _repo_with_three_commits(tmp_path) + assert is_ancestor(repo, shas[0], shas[2]) is True + + +def test_is_ancestor_true_for_equal_commit(tmp_path: Path) -> None: + repo, shas = _repo_with_three_commits(tmp_path) + assert is_ancestor(repo, shas[1], shas[1]) is True + + +def test_is_ancestor_false_for_later_commit(tmp_path: Path) -> None: + repo, shas = _repo_with_three_commits(tmp_path) + assert is_ancestor(repo, shas[2], shas[0]) is False + + +def test_is_ancestor_none_for_unknown_commit(tmp_path: Path) -> None: + repo, shas = _repo_with_three_commits(tmp_path) + assert is_ancestor(repo, "f" * 40, shas[0]) is None + + +def test_commits_between_counts_distance(tmp_path: Path) -> None: + repo, shas = _repo_with_three_commits(tmp_path) + assert commits_between(repo, shas[0], shas[2]) == 2 + + +def test_commits_between_zero_for_same(tmp_path: Path) -> None: + repo, shas = _repo_with_three_commits(tmp_path) + assert commits_between(repo, shas[1], shas[1]) == 0 + + +def test_commits_between_none_for_unknown(tmp_path: Path) -> None: + repo, shas = _repo_with_three_commits(tmp_path) + assert commits_between(repo, "f" * 40, shas[0]) is None +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `uv run pytest tests/test_git_reachability.py -v` +Expected: FAIL — `ImportError: cannot import name 'resolve_commit'` (and the others). + +- [ ] **Step 3: Implement the helpers** + +In `src/warpline/git.py`, add at module level (after the existing `_git_optional`, ~`git.py:82`): + +```python +def resolve_commit(repo: Path, ref: str) -> str | None: + """Resolve ``ref`` to a 40-hex commit object SHA, or None if unresolvable. + + Uses ``rev-parse --verify ^{commit}`` so a tag/branch/``HEAD`` resolves + to the underlying commit and a non-commit object is rejected. Never raises: + a bad ref returns None for the caller to turn into a structured error. + """ + + out = _git_optional(repo, ["rev-parse", "--verify", "--quiet", f"{ref}^{{commit}}"]) + if out is None: + return None + out = out.strip() + return out if len(out) == 40 else None + + +def is_ancestor(repo: Path, ancestor: str, descendant: str) -> bool | None: + """Is ``ancestor`` an ancestor-or-equal of ``descendant``? + + Wraps ``git merge-base --is-ancestor``: exit 0 -> True, exit 1 -> False, any + other exit (unknown/missing commit, shallow clone) -> None ("could not + compute" — fail-soft, never a crash, never a silent False). + """ + + proc = subprocess.run( + ["git", "merge-base", "--is-ancestor", ancestor, descendant], + cwd=repo, + check=False, + capture_output=True, + ) + if proc.returncode == 0: + return True + if proc.returncode == 1: + return False + return None + + +def commits_between(repo: Path, ancestor: str, descendant: str) -> int | None: + """Count commits in ``ancestor..descendant`` (excludes ancestor), or None. + + ``git rev-list --count ancestor..descendant``. None on any git failure + (unknown commit, etc.). Zero when the two are the same commit. + """ + + proc = subprocess.run( + ["git", "rev-list", "--count", f"{ancestor}..{descendant}"], + cwd=repo, + check=False, + text=True, + capture_output=True, + ) + if proc.returncode != 0: + return None + try: + return int(proc.stdout.strip()) + except ValueError: + return None +``` + +(`subprocess` and `Path` are already imported in `git.py`.) + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `uv run pytest tests/test_git_reachability.py -v` +Expected: PASS (9 tests). + +- [ ] **Step 5: Types check** + +Run: `uv run mypy src/warpline/git.py` +Expected: no errors. + +- [ ] **Step 6: Commit** + +```bash +git add src/warpline/git.py tests/test_git_reachability.py +git commit -m "feat(git): is_ancestor / commits_between / resolve_commit reachability helpers" +``` + +--- + +## Task 3: pure freshness compute (`verification.py`) + +**Files:** +- Create: `src/warpline/verification.py` +- Test: `tests/test_verification_compose.py` (create) + +**Interfaces:** +- Consumes: `listing.reason` (`listing.py:34-44`). NO store, NO git, NO I/O — purity is enforced by the import list (mirrors `_enrichment.py`). +- Produces: `compose_verification_freshness(...) -> dict` with this exact contract: + +```python +def compose_verification_freshness( + entity_change_commits: list[str], # this entity's change commit SHAs, OLDEST-first + verification_events: list[dict], # repo verification events, OLDEST-first; each has "commit_sha", "verified_at" + covers: Callable[[str, str], bool | None], # covers(verified_commit, change_commit): True/False/None(unavailable) + commits_between: Callable[[str, str], int | None], # commits_between(ancestor, descendant) for decay +) -> dict: + # returns: + # { + # "state": "fresh" | "stale" | "unverified" | "unavailable", + # "last_verified_at": str | None, + # "last_verified_commit": str | None, + # "decay": {"commits_behind": int | None}, + # "reason": , + # } +``` + +Semantics (the truth table the tests lock): + +| Condition | state | +|-----------|-------| +| `entity_change_commits` empty | `unverified` (nothing to verify) | +| Some event covers the LATEST change commit (`covers(V, latest) is True`) | `fresh` | +| No event covers latest, but `covers(...)` returned `None` for an undetermined check that could otherwise be fresh | `unavailable` | +| No event covers latest, but some event covers an EARLIER change | `stale` | +| Events exist but none cover any change (all `False`) / no events at all | `unverified` | + +Reason mapping (reuse canonical 11): +- `fresh` → `reason("clean")` +- `stale` → `reason("stale", cause=..., fix=...)` +- `unverified` → `reason("disabled", cause=..., fix=...)` (no gate pass recorded/covering) +- `unavailable` → `reason("unreachable", cause=..., fix=...)` (git reachability could not be computed) + +`last_verified_commit`/`last_verified_at` = the most-recent (by `verified_at`) event that covers some change of this entity (`None` if none). `decay.commits_behind`: `0` for `fresh`; `commits_between(last_covering_commit, latest_change)` for `stale`; `None` for `unverified`/`unavailable`. + +- [ ] **Step 1: Write the failing unit tests (vectors-first)** + +Create `tests/test_verification_compose.py`: + +```python +from __future__ import annotations + +from warpline.verification import compose_verification_freshness + + +def _covers_set(covered_pairs: set[tuple[str, str]]): + """covers(V, C) True iff (V, C) in the set; default False.""" + + def covers(verified: str, change: str) -> bool | None: + return (verified, change) in covered_pairs + + return covers + + +def _between_const(value): + def between(ancestor: str, descendant: str) -> int | None: + return value + + return between + + +def test_empty_changes_is_unverified() -> None: + out = compose_verification_freshness([], [], _covers_set(set()), _between_const(0)) + assert out["state"] == "unverified" + assert out["reason"]["reason_class"] == "disabled" + assert out["reason"]["cause"] and out["reason"]["fix"] + assert out["decay"]["commits_behind"] is None + + +def test_fresh_when_latest_change_covered() -> None: + events = [{"commit_sha": "V1", "verified_at": "2026-06-25T10:00:00+00:00"}] + out = compose_verification_freshness( + ["C0", "C1"], events, _covers_set({("V1", "C1"), ("V1", "C0")}), _between_const(5) + ) + assert out["state"] == "fresh" + assert out["last_verified_commit"] == "V1" + assert out["last_verified_at"] == "2026-06-25T10:00:00+00:00" + assert out["decay"]["commits_behind"] == 0 + assert out["reason"]["reason_class"] == "clean" + + +def test_stale_when_only_earlier_change_covered() -> None: + events = [{"commit_sha": "V1", "verified_at": "2026-06-25T10:00:00+00:00"}] + # V1 covers C0 (earlier) but NOT C1 (latest). + out = compose_verification_freshness( + ["C0", "C1"], events, _covers_set({("V1", "C0")}), _between_const(2) + ) + assert out["state"] == "stale" + assert out["last_verified_commit"] == "V1" + assert out["decay"]["commits_behind"] == 2 + assert out["reason"]["reason_class"] == "stale" + assert out["reason"]["cause"] and out["reason"]["fix"] + + +def test_unverified_when_no_event_covers_any_change() -> None: + events = [{"commit_sha": "V1", "verified_at": "2026-06-25T10:00:00+00:00"}] + out = compose_verification_freshness( + ["C0", "C1"], events, _covers_set(set()), _between_const(0) + ) + assert out["state"] == "unverified" + assert out["last_verified_commit"] is None + assert out["decay"]["commits_behind"] is None + assert out["reason"]["reason_class"] == "disabled" + + +def test_unverified_when_no_events_at_all() -> None: + out = compose_verification_freshness( + ["C0"], [], _covers_set(set()), _between_const(0) + ) + assert out["state"] == "unverified" + assert out["reason"]["reason_class"] == "disabled" + + +def test_unavailable_when_reachability_undetermined() -> None: + events = [{"commit_sha": "V1", "verified_at": "2026-06-25T10:00:00+00:00"}] + + def covers(verified: str, change: str) -> bool | None: + return None # git could not compute (shallow clone / missing commit) + + out = compose_verification_freshness(["C0", "C1"], events, covers, _between_const(0)) + assert out["state"] == "unavailable" + assert out["last_verified_commit"] is None + assert out["decay"]["commits_behind"] is None + assert out["reason"]["reason_class"] == "unreachable" + assert out["reason"]["cause"] and out["reason"]["fix"] + + +def test_most_recent_covering_event_wins_last_verified() -> None: + events = [ + {"commit_sha": "V1", "verified_at": "2026-06-25T09:00:00+00:00"}, + {"commit_sha": "V2", "verified_at": "2026-06-25T11:00:00+00:00"}, + ] + # Both cover latest; the later-verified_at one is reported. + out = compose_verification_freshness( + ["C1"], events, _covers_set({("V1", "C1"), ("V2", "C1")}), _between_const(0) + ) + assert out["state"] == "fresh" + assert out["last_verified_commit"] == "V2" + assert out["last_verified_at"] == "2026-06-25T11:00:00+00:00" + + +def test_unavailable_when_latest_undetermined_even_if_earlier_covered() -> None: + # Fail-soft precedence: if git cannot decide the LATEST change's coverage, + # the state is 'unavailable' even when an EARLIER change is covered — never + # 'stale' (which would falsely imply we KNOW it drifted) and never 'fresh'. + events = [{"commit_sha": "V1", "verified_at": "2026-06-25T10:00:00+00:00"}] + + def covers(verified: str, change: str) -> bool | None: + if change == "C1": + return None # latest change: git cannot decide + return True # earlier change C0: covered + + out = compose_verification_freshness(["C0", "C1"], events, covers, _between_const(0)) + assert out["state"] == "unavailable" + assert out["reason"]["reason_class"] == "unreachable" +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `uv run pytest tests/test_verification_compose.py -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'warpline.verification'`. + +- [ ] **Step 3: Implement `verification.py`** + +Create `src/warpline/verification.py`: + +```python +"""Pure verification-freshness compute (internal API). + +Mirrors ``_enrichment.py``: enrich-only, no store, no git, no I/O — git +reachability is injected as the ``covers`` / ``commits_between`` callables. The +import list (``typing`` + ``warpline.listing.reason``) is the structural proof +that this module cannot gate, mirror a sibling, or perform I/O. + +Freshness asks: has the entity's LATEST change been proven good by a recorded +gate run? A gate run at commit ``V`` "covers" a change at commit ``C`` iff ``C`` +is an ancestor-or-equal of ``V`` (the gate ran at or after the change landed). +Absence is always EXPLAINED via a weft-reason triple; it never reads as verified. +""" + +from __future__ import annotations + +from typing import Any, Callable + +from warpline.listing import reason + + +def _latest_covering_event( + change_commits: list[str], + events: list[dict[str, Any]], + covers: Callable[[str, str], bool | None], +) -> tuple[dict[str, Any] | None, bool]: + """Return (most-recent event covering ANY change, saw_undetermined). + + ``events`` is oldest-first, so the last covering event by iteration is the + most-recent by ``verified_at``. ``saw_undetermined`` is True if any + ``covers`` call returned None (git could not decide) — the caller uses it to + fail-soft to ``unavailable`` rather than claim a clean ``unverified``. + """ + + latest: dict[str, Any] | None = None + saw_undetermined = False + for event in events: + verified_commit = str(event.get("commit_sha")) + for change_commit in change_commits: + result = covers(verified_commit, change_commit) + if result is None: + saw_undetermined = True + elif result is True: + latest = event # later events overwrite -> most-recent wins + break + return latest, saw_undetermined + + +def compose_verification_freshness( + entity_change_commits: list[str], + verification_events: list[dict[str, Any]], + covers: Callable[[str, str], bool | None], + commits_between: Callable[[str, str], int | None], +) -> dict[str, Any]: + """Compose the per-entity verification-freshness block. See module docstring.""" + + if not entity_change_commits: + return _unverified("the entity has no recorded change commits to verify") + + latest_change = entity_change_commits[-1] # oldest-first input -> latest is last + + # Is the LATEST change covered by any event? (fresh wins outright.) + latest_saw_undetermined = False + fresh_event: dict[str, Any] | None = None + for event in verification_events: + result = covers(str(event.get("commit_sha")), latest_change) + if result is None: + latest_saw_undetermined = True + elif result is True: + fresh_event = event # most-recent covering event wins (oldest-first) + + if fresh_event is not None: + return { + "state": "fresh", + "last_verified_at": fresh_event.get("verified_at"), + "last_verified_commit": fresh_event.get("commit_sha"), + "decay": {"commits_behind": 0}, + "reason": reason("clean"), + } + + # Not fresh. If git could not decide the latest-change coverage, fail soft. + if latest_saw_undetermined: + return _unavailable() + + # Latest definitively uncovered (all covers() returned False, no None — else + # we'd have returned unavailable above). Does any event cover an EARLIER + # change? Check only [:-1] — the latest is already known uncovered, so + # re-checking it would waste a covers() call. + covering_event, earlier_undetermined = _latest_covering_event( + entity_change_commits[:-1], verification_events, covers + ) + if covering_event is not None: + last_commit = str(covering_event.get("commit_sha")) + return { + "state": "stale", + "last_verified_at": covering_event.get("verified_at"), + "last_verified_commit": covering_event.get("commit_sha"), + "decay": {"commits_behind": commits_between(last_commit, latest_change)}, + "reason": reason( + "stale", + cause=( + "the entity changed since it was last proven good: its latest change " + "commit is not covered by any recorded verification event" + ), + fix=( + "re-run your gate (tests/CI) at HEAD and record it with " + "`warpline verify-record --commit HEAD --kind test_pass`" + ), + ), + } + + if earlier_undetermined: + return _unavailable() + return _unverified( + "no recorded verification event covers any of the entity's change commits" + ) + + +def _unverified(cause: str) -> dict[str, Any]: + return { + "state": "unverified", + "last_verified_at": None, + "last_verified_commit": None, + "decay": {"commits_behind": None}, + "reason": reason( + "disabled", + cause=cause, + fix=( + "record a gate pass after your tests/CI run with " + "`warpline verify-record --commit --kind test_pass`; until then " + "verification is honestly unverified, not an earned-clean" + ), + ), + } + + +def _unavailable() -> dict[str, Any]: + return { + "state": "unavailable", + "last_verified_at": None, + "last_verified_commit": None, + "decay": {"commits_behind": None}, + "reason": reason( + "unreachable", + cause=( + "git reachability between the entity's change commits and the recorded " + "verification commits could not be computed (e.g. shallow clone or a " + "missing commit object)" + ), + fix=( + "fetch full history (unshallow the clone) so commit ancestry is " + "resolvable, then re-query; until then freshness is honestly unavailable" + ), + ), + } +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `uv run pytest tests/test_verification_compose.py -v` +Expected: PASS (8 tests). + +- [ ] **Step 5: Types + lint** + +Run: `uv run mypy src/warpline/verification.py && uv run ruff check src/warpline/verification.py` +Expected: no errors. + +- [ ] **Step 6: Commit** + +```bash +git add src/warpline/verification.py tests/test_verification_compose.py +git commit -m "feat(verification): pure compose_verification_freshness (fresh/stale/unverified/unavailable)" +``` + +--- + +## Task 4: `verify-record` verb (CLI + MCP) + +**Files:** +- Modify: `src/warpline/commands.py` (add `SCHEMA_VERIFICATION_RECORD` + `verify_record`) +- Modify: `src/warpline/cli.py` (subparser + dispatch) +- Modify: `src/warpline/mcp.py` (tool spec + handler + consumes) +- Modify: `docs/reference/cli.md`, `docs/reference/mcp-tools.md` +- Test: `tests/test_verify_record.py` (create) + +**Interfaces:** +- Consumes: `WarplineStore` accessors from Task 1; `git.resolve_commit` from Task 2; `build_envelope`/`enrichment_state` (`envelope.py`); `errors.WarplineError`; `_utc_now_iso` (see Step 3 — reuse an existing UTC-now helper if one exists; grep first). +- Produces: + - `commands.SCHEMA_VERIFICATION_RECORD = "warpline.verification_record.v1"`. + - `commands.verify_record(repo: Path, *, commit: str, kind: str, actor: str | None = None, now: str | None = None) -> dict[str, Any]` — resolves `commit` to an object SHA, validates `kind` non-empty, records the event, returns the standard envelope. `now` is an injectable ISO-8601 timestamp for tests (defaults to current UTC). + - CLI `warpline verify-record --commit --kind [--actor ] [--json]`. + - MCP tool `warpline_verification_record` (shim `verify_record`). + +- [ ] **Step 1: Write the failing tests** + +Create `tests/test_verify_record.py`: + +```python +from __future__ import annotations + +import subprocess +from pathlib import Path + +import pytest + +from warpline import commands +from warpline.errors import WarplineError +from warpline.store import WarplineStore, default_store_path + + +def _git_repo(tmp_path: Path) -> tuple[Path, str]: + repo = tmp_path / "r" + repo.mkdir() + for args in ( + ["init", "-q"], + ["config", "user.email", "t@t"], + ["config", "user.name", "t"], + ): + subprocess.run(["git", *args], cwd=repo, check=True, capture_output=True) + (repo / "f.txt").write_text("hello\n") + subprocess.run(["git", "add", "."], cwd=repo, check=True, capture_output=True) + subprocess.run(["git", "commit", "-q", "-m", "c0"], cwd=repo, check=True, capture_output=True) + sha = subprocess.run( + ["git", "rev-parse", "HEAD"], cwd=repo, check=True, text=True, capture_output=True + ).stdout.strip() + return repo, sha + + +def test_mcp_module_imports() -> None: + # mcp.py runs assert_inputschema_consumed() + a strict zip at IMPORT time; a + # missing consume-declaration or handler crashes here. Keep this first so an + # import crash is distinguishable from a metadata-assertion failure below. + from warpline import mcp + + assert mcp.TOOL_SPECS + + +def test_verify_record_stores_resolved_sha(tmp_path: Path) -> None: + repo, sha = _git_repo(tmp_path) + env = commands.verify_record( + repo, commit="HEAD", kind="test_pass", actor="ci", now="2026-06-25T10:00:00+00:00" + ) + assert env["ok"] is True + assert env["schema"] == "warpline.verification_record.v1" + # The SYMBOLIC ref HEAD must be stored as the resolved 40-hex object SHA. + assert env["data"]["commit_sha"] == sha + assert env["data"]["kind"] == "test_pass" + assert env["data"]["actor"] == "ci" + assert env["data"]["source"] == "warpline" + with WarplineStore.open(default_store_path(repo)) as store: + events = store.list_verification_events(repo) + assert len(events) == 1 + assert events[0]["commit_sha"] == sha + + +def test_verify_record_envelope_is_local_only(tmp_path: Path) -> None: + repo, _ = _git_repo(tmp_path) + env = commands.verify_record(repo, commit="HEAD", kind="test_pass") + assert env["meta"]["local_only"] is True + assert env["meta"]["peer_side_effects"] == [] + + +def test_verify_record_is_idempotent(tmp_path: Path) -> None: + repo, _ = _git_repo(tmp_path) + commands.verify_record(repo, commit="HEAD", kind="test_pass", now="2026-06-25T10:00:00+00:00") + env2 = commands.verify_record(repo, commit="HEAD", kind="test_pass", now="2026-06-25T10:00:00+00:00") + assert env2["data"]["idempotency"] == "already_recorded" + with WarplineStore.open(default_store_path(repo)) as store: + assert len(store.list_verification_events(repo)) == 1 + + +def test_verify_record_idempotent_across_different_timestamps(tmp_path: Path) -> None: + # verified_at is NOT part of UNIQUE(repo_id, commit_sha, kind, source), so a + # re-record at a DIFFERENT time must still collapse to a single row. + repo, _ = _git_repo(tmp_path) + commands.verify_record(repo, commit="HEAD", kind="test_pass", now="2026-06-25T10:00:00+00:00") + commands.verify_record(repo, commit="HEAD", kind="test_pass", now="2026-06-25T23:00:00+00:00") + with WarplineStore.open(default_store_path(repo)) as store: + assert len(store.list_verification_events(repo)) == 1 + + +def test_verify_record_in_detached_head(tmp_path: Path) -> None: + # CI commonly runs on a detached HEAD. verify_record(commit="HEAD") must still + # resolve HEAD to the detached commit's object SHA and store that. + repo, sha = _git_repo(tmp_path) + subprocess.run(["git", "checkout", "-q", sha], cwd=repo, check=True, capture_output=True) + env = commands.verify_record(repo, commit="HEAD", kind="ci_pass", now="2026-06-25T10:00:00+00:00") + assert env["data"]["commit_sha"] == sha + + +def test_cli_verify_record_bad_commit_does_not_exit_zero(tmp_path: Path) -> None: + # Mirror tests/test_cli_dispatch.py's invocation style (read it first). A bad + # --commit must not return success. If cli.main has a top-level WarplineError + # handler producing an ok:false envelope + nonzero return, assert that; + # otherwise assert the non-zero/raised outcome the existing verbs produce. + from warpline import cli + + repo, _ = _git_repo(tmp_path) + try: + rc = cli.main( + ["verify-record", "--repo", str(repo), "--commit", "no-such-ref", + "--kind", "test_pass", "--json"] + ) + except Exception: + return # surfaced as an exception (traceback) -> not a success path + assert rc != 0 + + +def test_verify_record_bad_ref_raises_structured_error(tmp_path: Path) -> None: + repo, _ = _git_repo(tmp_path) + with pytest.raises(WarplineError) as exc: + commands.verify_record(repo, commit="no-such-ref", kind="test_pass") + data = exc.value.to_error_data() + assert data["error_code"] == "invalid_rev_range" + assert data["rejected_field"] == "commit" + # No row written. + with WarplineStore.open(default_store_path(repo)) as store: + assert store.list_verification_events(repo) == [] + + +def test_verify_record_empty_kind_raises_structured_error(tmp_path: Path) -> None: + repo, _ = _git_repo(tmp_path) + with pytest.raises(WarplineError) as exc: + commands.verify_record(repo, commit="HEAD", kind=" ") + data = exc.value.to_error_data() + assert data["rejected_field"] == "kind" + + +def test_mcp_lists_verification_record_tool_with_mutating_metadata() -> None: + from warpline import mcp + + names = {spec["endorsed"] for spec in mcp.TOOL_SPECS} + assert "warpline_verification_record" in names + spec = next(s for s in mcp.TOOL_SPECS if s["endorsed"] == "warpline_verification_record") + meta = spec["metadata"] + assert meta["read_only"] is False + assert meta["writes_local_state"] is True + assert meta["mutates_paths"] == [".weft/warpline/"] + assert meta["local_only"] is True + assert meta["peer_side_effects"] == [] + # Both endorsed + shim dispatch to a handler. + assert "warpline_verification_record" in mcp._HANDLERS + assert "verify_record" in mcp._HANDLERS +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `uv run pytest tests/test_verify_record.py -v` +Expected: FAIL — `commands.verify_record` does not exist; `warpline_verification_record` not in `TOOL_SPECS`. + +- [ ] **Step 3: Add the schema constant + command** + +In `src/warpline/commands.py`, add the schema constant beside the others (`commands.py:44-50`): + +```python +SCHEMA_VERIFICATION_RECORD = "warpline.verification_record.v1" +``` + +Reuse the existing timestamp helper: `_now()` already exists at `commands.py:556` (returns a `datetime`; the module imports `from datetime import UTC, datetime`). Use `_now().isoformat()` — do NOT add a second now-helper. + +Add the command (place it near `capture_snapshot`, after `commands.py:1149`). Confirm `build_envelope`, `enrichment_state`, `default_store_path`, and `_now` are available in `commands.py`; add the missing imports (`from warpline.git import resolve_commit`, and `from warpline.errors import BadRevisionError, MissingRequiredFieldError` — check what is already imported and only add what is missing): + +```python +def verify_record( + repo: Path, + *, + commit: str, + kind: str, + actor: str | None = None, + now: str | None = None, +) -> dict[str, Any]: + """Record a verification (gate-pass) event for ``commit``. + + The 2nd mutating verb (besides capture-snapshot). Writes ONE row to the + local ``verification_events`` table (``.weft/warpline/`` only); never a + sibling repo. ``commit`` is resolved to an object SHA before storage — a + symbolic ref is never persisted. ``kind`` is a free-form non-empty provenance + label (e.g. ``test_pass`` / ``ci_pass`` / ``gate_pass``). Idempotent on + (repo, commit, kind, source=warpline). + """ + + kind_clean = kind.strip() + if not kind_clean: + raise MissingRequiredFieldError( + "kind must be a non-empty verification label, e.g. test_pass", + rejected_field="kind", + ) + resolved = resolve_commit(repo, commit) + if resolved is None: + raise BadRevisionError( + f"could not resolve commit ref {commit!r} to an object SHA", + rejected_field="commit", + ) + verified_at = now or _now().isoformat() + with WarplineStore.open(default_store_path(repo)) as store: + repo_id = store.ensure_repo(repo) + inserted = store.record_verification_event( + repo_id=repo_id, + commit_sha=resolved, + kind=kind_clean, + verified_at=verified_at, + actor=actor, + source="warpline", + ) + data = { + "commit_sha": resolved, + "kind": kind_clean, + "verified_at": verified_at, + "actor": actor, + "source": "warpline", + "idempotency": "recorded" if inserted else "already_recorded", + } + query = { + "repo": str(repo), + "tool": "warpline_verification_record", + "arguments": {"commit": commit, "kind": kind, "actor": actor}, + "filters": {}, + "sort": {}, + "page": {"limit": None, "cursor": None}, + } + return build_envelope( + SCHEMA_VERIFICATION_RECORD, + query=query, + data=data, + enrichment=enrichment_state(), + warnings=[], + ) +``` + +> Error-class rationale (verified against `errors.py`): `MissingRequiredFieldError` (`errors.py:70-73`, `code="missing_required_field"`, `retryability="retry_with_changes"`) is the semantically correct error for a blank `kind` — do NOT use `InvalidChangedRefsError` (its `code="invalid_changed_refs"` would misleadingly point a caller at the `changed_refs` input). `BadRevisionError` (`errors.py:83-87`, `code="invalid_rev_range"`) is correct for an unresolvable commit ref; its default `rejected_field` is `"rev_range"`, and passing `rejected_field="commit"` overrides it (the `WarplineError.__init__` honors the kwarg — `errors.py:26-67`). + +- [ ] **Step 4: Wire the CLI** + +In `src/warpline/cli.py`, add the subparser beside `capture-snapshot` (`cli.py:334-339`): + +```python + verify_record_parser = sub.add_parser("verify-record") + verify_record_parser.add_argument("--repo", type=Path, default=Path(".")) + verify_record_parser.add_argument("--commit", required=True) + verify_record_parser.add_argument("--kind", required=True) + verify_record_parser.add_argument("--actor") + verify_record_parser.add_argument("--json", action="store_true") +``` + +And the dispatch beside `capture-snapshot`'s (`cli.py:525-532`): + +```python + if args.command == "verify-record": + payload = commands.verify_record( + args.repo, + commit=args.commit, + kind=args.kind, + actor=args.actor, + ) + print( + json.dumps(payload, sort_keys=True) + if args.json + else json.dumps(payload, indent=2) + ) + return 0 +``` + +> If `cli.py` wraps command calls to convert `WarplineError` into a printed error envelope + nonzero exit (check how `capture-snapshot`/`changed` handle errors — grep `WarplineError` in `cli.py`), follow that same pattern so a bad `--commit` exits cleanly rather than tracebacks. + +- [ ] **Step 5: Wire the MCP tool** + +In `src/warpline/mcp.py`, add the handler beside `_h_capture` (`mcp.py:423-433`): + +```python +def _h_verify_record(args: dict[str, Any]) -> dict[str, Any]: + return commands.verify_record( + _repo_arg(args), + commit=str(args.get("commit", "")), + kind=str(args.get("kind", "")), + actor=_opt_str(args, "actor"), + ) +``` + +> Check `_opt_str` exists in `mcp.py` (the capture handler uses helpers for optional args). If not, inline: `actor=(str(args["actor"]) if args.get("actor") is not None else None)`. + +Add the tool spec to `TOOL_SPECS` after the `warpline_edge_snapshot_capture` spec (`mcp.py:217-242`): + +```python + _tool_spec( + endorsed="warpline_verification_record", + shim="verify_record", + schema=commands.SCHEMA_VERIFICATION_RECORD, + description=( + "Record a verification (gate-pass) for a commit, e.g. test_pass. Mutates ONLY " + ".weft/warpline state; never a sibling repo. Advisory; warpline never gates." + ), + input_properties={ + "commit": {"type": "string"}, + "kind": {"type": "string"}, + "actor": {"type": ["string", "null"]}, + }, + required=["repo", "commit", "kind"], + metadata=_metadata( + read_only=False, + writes_local_state=True, + idempotent=True, + mutates_paths=[".weft/warpline/"], + federation_dependencies=[], + ), + ), +``` + +Add `_h_verify_record` to the `_HANDLERS` zip handler list (`mcp.py:436-443`) — append it AFTER `_h_capture` so the indexed order matches `TOOL_SPECS`: + +```python +for _spec, _handler in zip( + TOOL_SPECS, + [_h_change_list, _h_timeline, _h_churn, _h_impact, _h_reverify, _h_capture, _h_verify_record], + strict=True, +): +``` + +**REQUIRED (not conditional) — the consume declarations.** `mcp.py` runs `assert_inputschema_consumed()` at **import time** (the assert is at `mcp.py:574`); it crashes the whole MCP module — making *every* warpline tool uncallable and failing `mcp-smoke`/`dogfood` — if the new tool is missing from BOTH maps. Add both: + +To `_HANDLER_CONSUMES` (`mcp.py:510-521`) — all four advertised fields are consumed by `_h_verify_record`: + +```python + "warpline_verification_record": frozenset({"repo", "commit", "kind", "actor"}), +``` + +To `_KNOWN_FASTFOLLOW_DEAD` (the companion map near `mcp.py:531`) — no advertised-but-unconsumed fields, so an empty set: + +```python + "warpline_verification_record": frozenset(), +``` + +Grep to confirm both map names and the assert: `grep -n "_HANDLER_CONSUMES\|_KNOWN_FASTFOLLOW_DEAD\|assert_inputschema_consumed" src/warpline/mcp.py`. Both endorsed-name keys are required; the shim (`verify_record`) is handled by the existing consume logic — match how `warpline_edge_snapshot_capture` is keyed (endorsed name only) and mirror it exactly. + +- [ ] **Step 5b: Fix the contract-fixture mutating-tool assertion + regenerate the static inventory (REQUIRED)** + +A second mutating tool breaks two existing contract checks. Grep first: `grep -rn "is_capture\|mutates\|mcp-tool-inventory" tests/contracts/ tests/fixtures/contracts/`. + +1. `tests/contracts/test_warpline_contract_fixtures.py:54-55` reads: + ```python + is_capture = tool["name"] in {"capture_snapshot", "warpline_edge_snapshot_capture"} + assert tool["mutates"] is is_capture + ``` + This asserts the capture tool is the ONLY mutating tool. Generalize it (rename `is_capture` → `is_mutating`) to include the new tool: + ```python + is_mutating = tool["name"] in { + "capture_snapshot", "warpline_edge_snapshot_capture", + "verify_record", "warpline_verification_record", + } + assert tool["mutates"] is is_mutating + ``` +2. `tests/fixtures/contracts/warpline/mcp-tool-inventory.json` is a STATIC snapshot of `tools/list`. It must be regenerated so the two new tool names appear with `mutates: true` and the correct metadata. Find how it is generated (grep for a regenerate script or a `--update`/`--regen` flag, or a test that writes it): `grep -rn "mcp-tool-inventory\|inventory" tests/ scripts/ src/warpline/`. If a regenerate command exists, run it; otherwise hand-add the two tool entries mirroring the `warpline_edge_snapshot_capture` entry's shape (name, inputSchema, full metadata block) for both `warpline_verification_record` and its `verify_record` shim. Then run `uv run pytest tests/contracts/test_warpline_contract_fixtures.py -v` and confirm green. + +> Note: there is a pre-existing open follow-up `warpline-fc09bdeddd` about contract-fixture drift (a missing `enrichment_reasons` key in fixtures). That is SEPARATE from this step (different fixture concern) — do not conflate; just make THIS tool's inventory entries correct. + +- [ ] **Step 6: Run the tests to verify they pass** + +Run: `uv run pytest tests/test_verify_record.py tests/contracts/test_warpline_contract_fixtures.py -v` +Expected: PASS (8 verb tests + the contract fixtures). + +- [ ] **Step 7: MCP smoke + types** + +Run: `uv run warpline mcp-smoke --repo . --json && uv run mypy src/warpline` +Expected: `mcp-smoke` reports `ok: true`; mypy clean. + +- [ ] **Step 8: Document the verb** + +In `docs/reference/cli.md`, add a `verify-record` entry mirroring the `capture-snapshot` entry's format (flags `--commit` (required), `--kind` (required), `--actor`, `--json`; one-line description: "Record a local gate-pass verification event for a commit (advisory; warpline never gates)."). In `docs/reference/mcp-tools.md`, add `warpline_verification_record` (shim `verify_record`) to the tool table/list with its inputs and the mutating/`local_only` metadata, mirroring the `warpline_edge_snapshot_capture` row. + +- [ ] **Step 9: Commit** + +```bash +git add src/warpline/commands.py src/warpline/cli.py src/warpline/mcp.py \ + src/warpline/errors.py docs/reference/cli.md docs/reference/mcp-tools.md \ + tests/test_verify_record.py tests/contracts/test_warpline_contract_fixtures.py \ + tests/fixtures/contracts/warpline/mcp-tool-inventory.json +git commit -m "feat: verify-record verb (CLI + MCP, 2nd mutating tool)" +``` +(Include `errors.py` only if you added/adjusted an error subclass; include the contract-fixture + inventory files from Step 5b.) + +--- + +## Task 5: reverify integration (per-item block, summary, advisory sort) + +**Files:** +- Modify: `src/warpline/reverify.py` (thread a per-item `verification` block) +- Modify: `src/warpline/commands.py` (`reverify_worklist`: build verification index, attach, summary, advisory sort) +- Test: `tests/test_reverify_verification.py` (create) + +**Interfaces:** +- Consumes: `verification.compose_verification_freshness` (Task 3); `git.is_ancestor`/`git.commits_between` (Task 2); `store.list_verification_events`/`store.list_change_events` (Task 1 + existing); the existing `reverify_worklist` pipeline (`commands.py:745-876`). +- Produces: + - Each worklist item gains `item["verification"]` (the dict from `compose_verification_freshness`). + - `data["verification_summary"] = {"fresh": int, "stale": int, "unverified": int, "unavailable": int, "local_source_configured": bool}`. + - Advisory sort: stale-of-trust surfaces first WITHIN the existing depth ordering; **no item removed**. + +**Design (data flow — read carefully):** +`enrich_blast` returns `changed`/`affected` whose `entity` view is `{locator, sei}` only (`entity_key_id` is dropped — `refs.py:entity_view` keeps the frozen view). But the upstream `result` from `compute_blast_radius` still carries `entity_key_id` per row, and `enrich_blast` preserves order. So: in `commands.py`, build aligned `entity_key_id` lists from `result["changed"]`/`result["affected"]`, compute a `verification` block per key id, and pass a `verification_for: Callable[[int | None], dict]` plus the aligned id lists into `render_reverify_worklist`, which attaches `item["verification"]` per row. This keeps the frozen entity view untouched and avoids fragile positional zips after sorting. + +- [ ] **Step 1: Write the failing integration tests** + +Create `tests/test_reverify_verification.py`. (Reuse the seeding style from `tests/contracts/test_golden_vectors.py`; this test drives the public `commands.reverify_worklist`.) + +```python +from __future__ import annotations + +import subprocess +from pathlib import Path + +from warpline import commands +from warpline.store import WarplineStore, default_store_path + + +def _git(repo: Path, *args: str) -> str: + return subprocess.run( + ["git", *args], cwd=repo, check=True, text=True, capture_output=True + ).stdout.strip() + + +def _repo(tmp_path: Path) -> Path: + repo = tmp_path / "r" + repo.mkdir() + _git(repo, "init", "-q") + _git(repo, "config", "user.email", "t@t") + _git(repo, "config", "user.name", "t") + return repo + + +def _commit(repo: Path, name: str, body: str) -> str: + (repo / name).write_text(body) + _git(repo, "add", ".") + _git(repo, "commit", "-q", "-m", f"touch {name}") + return _git(repo, "rev-parse", "HEAD") + + +def _seed_entity_change(store: WarplineStore, repo: Path, locator: str, commit_sha: str) -> int: + repo_id = store.ensure_repo(repo) + key_id = store.ensure_entity_key(repo_id, locator, None, commit_sha) + store.append_change_event( + repo_id=repo_id, + entity_key_id=key_id, + commit_sha=commit_sha, + path="m.py", + change_kind="modified", + actor="dev", + changed_at="2026-06-25T08:00:00+00:00", + ) + return key_id + + +def test_each_item_carries_a_verification_block(tmp_path: Path) -> None: + repo = _repo(tmp_path) + c0 = _commit(repo, "m.py", "v0\n") + with WarplineStore.open(default_store_path(repo)) as store: + key_id = _seed_entity_change(store, repo, "python:function:m.py::f", c0) + env = commands.reverify_worklist(repo, [key_id]) + items = env["data"]["items"] + assert items, "expected a non-empty worklist" + for item in items: + assert "verification" in item + assert item["verification"]["state"] in {"fresh", "stale", "unverified", "unavailable"} + assert "reason_class" in item["verification"]["reason"] + + +def test_unverified_when_no_verification_recorded(tmp_path: Path) -> None: + repo = _repo(tmp_path) + c0 = _commit(repo, "m.py", "v0\n") + with WarplineStore.open(default_store_path(repo)) as store: + key_id = _seed_entity_change(store, repo, "python:function:m.py::f", c0) + env = commands.reverify_worklist(repo, [key_id]) + summary = env["data"]["verification_summary"] + assert summary["local_source_configured"] is False + assert summary["unverified"] >= 1 + item = env["data"]["items"][0] + assert item["verification"]["state"] == "unverified" + assert item["verification"]["reason"]["reason_class"] == "disabled" + + +def test_fresh_when_change_is_verified(tmp_path: Path) -> None: + repo = _repo(tmp_path) + c0 = _commit(repo, "m.py", "v0\n") + with WarplineStore.open(default_store_path(repo)) as store: + key_id = _seed_entity_change(store, repo, "python:function:m.py::f", c0) + commands.verify_record(repo, commit=c0, kind="test_pass", now="2026-06-25T10:00:00+00:00") + env = commands.reverify_worklist(repo, [key_id]) + summary = env["data"]["verification_summary"] + assert summary["local_source_configured"] is True + assert summary["fresh"] >= 1 + item = next(i for i in env["data"]["items"] if i["reason"] == "changed") + assert item["verification"]["state"] == "fresh" + assert item["verification"]["last_verified_commit"] == c0 + + +def test_stale_when_change_lands_after_verification(tmp_path: Path) -> None: + repo = _repo(tmp_path) + c0 = _commit(repo, "m.py", "v0\n") + # Verify at c0, THEN the entity changes again at c1 (uncovered). + commands.verify_record(repo, commit=c0, kind="test_pass", now="2026-06-25T10:00:00+00:00") + c1 = _commit(repo, "m.py", "v1\n") + with WarplineStore.open(default_store_path(repo)) as store: + repo_id = store.ensure_repo(repo) + key_id = store.ensure_entity_key(repo_id, "python:function:m.py::f", None, c0) + for sha in (c0, c1): + store.append_change_event( + repo_id=repo_id, entity_key_id=key_id, commit_sha=sha, path="m.py", + change_kind="modified", actor="dev", changed_at="2026-06-25T08:00:00+00:00", + ) + env = commands.reverify_worklist(repo, [key_id]) + item = next(i for i in env["data"]["items"] if i["reason"] == "changed") + assert item["verification"]["state"] == "stale" + assert env["data"]["verification_summary"]["stale"] >= 1 + + +def test_verification_never_filters_items(tmp_path: Path) -> None: + repo = _repo(tmp_path) + c0 = _commit(repo, "m.py", "v0\n") + with WarplineStore.open(default_store_path(repo)) as store: + key_id = _seed_entity_change(store, repo, "python:function:m.py::f", c0) + baseline = commands.reverify_worklist(repo, [key_id]) + n_before = len(baseline["data"]["items"]) + # Recording verification must never REMOVE an item — only annotate/sort. + commands.verify_record(repo, commit=c0, kind="test_pass") + after = commands.reverify_worklist(repo, [key_id]) + assert len(after["data"]["items"]) == n_before + + +def test_envelope_stays_local_only(tmp_path: Path) -> None: + repo = _repo(tmp_path) + c0 = _commit(repo, "m.py", "v0\n") + with WarplineStore.open(default_store_path(repo)) as store: + key_id = _seed_entity_change(store, repo, "python:function:m.py::f", c0) + env = commands.reverify_worklist(repo, [key_id]) + assert env["meta"]["local_only"] is True + assert env["meta"]["peer_side_effects"] == [] + # verification must NOT have leaked into the frozen enrichment vocab. + assert "verification" not in env["enrichment"] + assert "verification" not in env["enrichment_reasons"] + + +def test_verification_summary_is_post_filter_zero_case(tmp_path: Path) -> None: + # Our entity has sei=None; filtering has_sei -> empty set -> all-zero summary. + repo = _repo(tmp_path) + c0 = _commit(repo, "m.py", "v0\n") + with WarplineStore.open(default_store_path(repo)) as store: + key_id = _seed_entity_change(store, repo, "python:function:m.py::f", c0) + env = commands.reverify_worklist(repo, [key_id], filters={"has_sei": True}) + assert env["data"]["items"] == [] + summary = env["data"]["verification_summary"] + assert summary["fresh"] == 0 + assert summary["stale"] == 0 + assert summary["unverified"] == 0 + assert summary["unavailable"] == 0 + + +def test_verification_summary_counts_only_filtered_subset(tmp_path: Path) -> None: + # Two entities; one HAS an sei, one does not. Filtering has_sei=True keeps + # exactly ONE. The summary must count 1, not 2 — proving it is computed on the + # POST-filter set (a pre-filter computation would report 2). + repo = _repo(tmp_path) + c0 = _commit(repo, "m.py", "v0\n") + with WarplineStore.open(default_store_path(repo)) as store: + repo_id = store.ensure_repo(repo) + with_sei = store.ensure_entity_key(repo_id, "python:function:m.py::f", "lw:eid:has", c0) + without_sei = store.ensure_entity_key(repo_id, "python:function:m.py::g", None, c0) + for kid in (with_sei, without_sei): + store.append_change_event( + repo_id=repo_id, entity_key_id=kid, commit_sha=c0, path="m.py", + change_kind="modified", actor="dev", changed_at="2026-06-25T08:00:00+00:00", + ) + env = commands.reverify_worklist(repo, [with_sei, without_sei], filters={"has_sei": True}) + assert len(env["data"]["items"]) == 1 + summary = env["data"]["verification_summary"] + total = summary["fresh"] + summary["stale"] + summary["unverified"] + summary["unavailable"] + assert total == 1 # NOT 2 — proves post-filter computation + + +def test_unavailable_when_reachability_fails(tmp_path: Path, monkeypatch) -> None: + repo = _repo(tmp_path) + c0 = _commit(repo, "m.py", "v0\n") + # A verification event must exist so covers() is actually consulted. + commands.verify_record(repo, commit=c0, kind="test_pass", now="2026-06-25T10:00:00+00:00") + with WarplineStore.open(default_store_path(repo)) as store: + key_id = _seed_entity_change(store, repo, "python:function:m.py::f", c0) + # Patch the name in commands' namespace (it imported is_ancestor by name). + monkeypatch.setattr(commands, "is_ancestor", lambda *a, **k: None) + env = commands.reverify_worklist(repo, [key_id]) + item = next(i for i in env["data"]["items"] if i["reason"] == "changed") + assert item["verification"]["state"] == "unavailable" + assert item["verification"]["reason"]["reason_class"] == "unreachable" + assert env["data"]["verification_summary"]["unavailable"] >= 1 + + +def test_stale_sorts_before_fresh_by_default(tmp_path: Path) -> None: + repo = _repo(tmp_path) + c0 = _commit(repo, "a.py", "v0\n") + commands.verify_record(repo, commit=c0, kind="test_pass", now="2026-06-25T10:00:00+00:00") + c1 = _commit(repo, "b.py", "v1\n") + with WarplineStore.open(default_store_path(repo)) as store: + repo_id = store.ensure_repo(repo) + a = store.ensure_entity_key(repo_id, "python:function:a.py::fa", None, c0) + store.append_change_event( + repo_id=repo_id, entity_key_id=a, commit_sha=c0, path="a.py", + change_kind="modified", actor="dev", changed_at="2026-06-25T08:00:00+00:00", + ) + b = store.ensure_entity_key(repo_id, "python:function:b.py::fb", None, c0) + for sha in (c0, c1): + store.append_change_event( + repo_id=repo_id, entity_key_id=b, commit_sha=sha, path="b.py", + change_kind="modified", actor="dev", changed_at="2026-06-25T08:00:00+00:00", + ) + # Seed FRESH first so the natural (pre-presort) order is fresh-then-stale; + # the presort must flip it. (Catches presort removal: without it the order + # stays fresh-first and this assertion fails.) + env = commands.reverify_worklist(repo, [a, b]) + states = [i["verification"]["state"] for i in env["data"]["items"]] + assert "stale" in states and "fresh" in states + assert states.index("stale") < states.index("fresh") # advisory: stale first + + +def test_fresh_when_verified_at_a_later_commit(tmp_path: Path) -> None: + # Asymmetric real-git case that catches a covers/is_ancestor argument SWAP: + # change at c0, verify at c1 (c1 is a DESCENDANT of c0). c0 is an ancestor of + # c1, so the change IS covered -> fresh. A swapped is_ancestor(verified, change) + # would compute is_ancestor(c1, c0) -> False and wrongly report not-fresh. + repo = _repo(tmp_path) + c0 = _commit(repo, "m.py", "v0\n") + c1 = _commit(repo, "n.py", "v0\n") # later commit, descendant of c0 + with WarplineStore.open(default_store_path(repo)) as store: + key_id = _seed_entity_change(store, repo, "python:function:m.py::f", c0) + commands.verify_record(repo, commit=c1, kind="test_pass", now="2026-06-25T10:00:00+00:00") + env = commands.reverify_worklist(repo, [key_id]) + item = next(i for i in env["data"]["items"] if i["reason"] == "changed") + assert item["verification"]["state"] == "fresh" + assert item["verification"]["last_verified_commit"] == c1 + + +def test_unavailable_when_change_commit_no_longer_exists(tmp_path: Path) -> None: + # Squash/rebase honesty: a change_event whose commit SHA was rewritten away + # (no longer a real object). With a recorded verification, git reachability + # cannot be computed -> 'unavailable'/'unreachable', NOT a silent 'unverified' + # (which would falsely imply "just needs verifying" instead of "trust unknown"). + repo = _repo(tmp_path) + c0 = _commit(repo, "m.py", "v0\n") + commands.verify_record(repo, commit=c0, kind="test_pass", now="2026-06-25T10:00:00+00:00") + ghost = "0" * 40 # a SHA that never existed (rewritten by squash/rebase) + with WarplineStore.open(default_store_path(repo)) as store: + repo_id = store.ensure_repo(repo) + key_id = store.ensure_entity_key(repo_id, "python:function:m.py::f", None, ghost) + store.append_change_event( + repo_id=repo_id, entity_key_id=key_id, commit_sha=ghost, path="m.py", + change_kind="modified", actor="dev", changed_at="2026-06-25T08:00:00+00:00", + ) + env = commands.reverify_worklist(repo, [key_id]) + item = next(i for i in env["data"]["items"] if i["reason"] == "changed") + assert item["verification"]["state"] == "unavailable" + assert item["verification"]["reason"]["reason_class"] == "unreachable" +``` + +> Before implementing, the engineer MUST verify the seeding helpers used here match real store signatures: `ensure_entity_key(repo_id, locator, sei, commit_sha) -> int` (`store.py:521`), `append_change_event(*, repo_id, entity_key_id, commit_sha, path, change_kind, actor, changed_at, ...)` (`store.py:881`). Adjust the test seeding if a signature differs. + +**ALSO write `test_stale_first_is_secondary_to_an_explicit_sort`** (prose, because it needs an edge snapshot to produce a depth≥1 item — do not hand-guess the edge direction). It proves stale-first is a SECONDARY tiebreak, not a primary override. Construct it by **mirroring an existing test that produces downstream/affected items** (grep: `grep -rln "capture_snapshot_atomic\|\"depth\": 1\|depth=1\|downstream" tests/`) — copy that test's exact edge-snapshot setup. Make the changed entity X **fresh** (verify at its change commit) and its downstream entity Y at depth 1 **stale** (Y changes again after the last covering verification). Then with the default sort assert: (a) `[it["depth"] for it in env["data"]["items"]] == sorted(...)` — depth stays the PRIMARY ordering; (b) the depth-0 fresh item precedes the depth-1 stale item — stale-first did NOT override the primary key; (c) where two items share a depth, the stale one precedes the fresh one. If a swapped placement (presort after `apply_sort`) were used, (a)/(b) would fail. This is the test that makes the placement fix non-vacuous. + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `uv run pytest tests/test_reverify_verification.py -v` +Expected: FAIL — items have no `verification` key; `data` has no `verification_summary`. (Note: `test_unavailable_when_reachability_fails` red-states as `AttributeError: module 'warpline.commands' has no attribute 'is_ancestor'` until Step 4 adds the `from warpline.git import is_ancestor, commits_between` import — that import form is REQUIRED for `monkeypatch.setattr(commands, "is_ancestor", ...)` to bind. Guard any `next(... for ... if ...)` in these tests with a preceding `assert any(...)` so a truncated worklist fails with a clear message, not `StopIteration`.) + +- [ ] **Step 3: Thread the per-item block through `render_reverify_worklist`** + +In `src/warpline/reverify.py`, change `render_reverify_worklist` to accept aligned key-id lists and a `verification_for` callable, and attach the block. Replace the signature and the row-building / item-building sections (`reverify.py:19-79`): + +```python +def render_reverify_worklist( + *, + changed: list[dict[str, Any]], + affected: list[dict[str, Any]], + completeness: str, + staleness: dict[str, Any], + work_client: WorkClient | None = None, + changed_key_ids: list[int | None] | None = None, + affected_key_ids: list[int | None] | None = None, + verification_for: Callable[[int | None], dict[str, Any]] | None = None, +) -> tuple[list[dict[str, Any]], bool, list[dict[str, Any]]]: + """Render the frozen reverify worklist items. + + Returns ``(items, work_seen, filigree_candidates)``. The changed entities are + always present (reason ``changed``) so a solo/NO_SNAPSHOT worklist is still + non-empty; downstream entities are added when a snapshot exists. + + ``verification_for`` (advisory, Rung 2 Track B) maps an ``entity_key_id`` to + its verification-freshness block; ``changed_key_ids`` / ``affected_key_ids`` + are aligned 1:1 with ``changed`` / ``affected`` so the block can be attached + without threading the internal key id into the FROZEN ``{locator, sei}`` + entity view. When ``verification_for`` is None the block defaults to an + honest ``unverified`` (no source configured). + """ + + ckids = changed_key_ids or [None] * len(changed) + akids = affected_key_ids or [None] * len(affected) + rows: list[tuple[dict[str, Any], str, int, list[Any], int | None]] = [] + for entry, kid in zip(changed, ckids): + rows.append((entry.get("entity", {}), "changed", 0, [], kid)) + for entry, kid in zip(affected, akids): + rows.append( + ( + entry.get("entity", {}), + "downstream", + entry.get("depth", 1), + entry.get("via_edges", []), + kid, + ) + ) + + items: list[dict[str, Any]] = [] + work_seen = False + candidates: list[dict[str, Any]] = [] + for entity, reason, depth, why, kid in rows: + enrichment = _empty_enrichment() + priority = "unknown" + sei = entity.get("sei") + if work_client is not None and isinstance(sei, str) and sei: + work_items = work_enrichment_for_sei(work_client, sei) + if work_items: + work_seen = True + enrichment["work"] = work_items + priority = priority_from_work(work_items) + for work_item in work_items: + candidates.append( + { + "proposed_action": "review_linked_issue", + "issue_id": work_item.get("issue_id"), + "entity": entity, + } + ) + verification = ( + verification_for(kid) if verification_for is not None else _default_verification() + ) + items.append( + { + "entity": entity, + "priority": priority, + "reason": reason, + "depth": depth, + "why": why, + "suggested_verification": _SUGGESTED_VERIFICATION, + "enrichment": enrichment, + "verification": verification, + } + ) + return items, work_seen, candidates +``` + +Add the imports + the default helper at the top of `reverify.py`: + +```python +from typing import Any, Callable + +from warpline.listing import reason +``` + +```python +def _default_verification() -> dict[str, Any]: + """Honest default when no verification source is wired (advisory).""" + + return { + "state": "unverified", + "last_verified_at": None, + "last_verified_commit": None, + "decay": {"commits_behind": None}, + "reason": reason( + "disabled", + cause="no local verification source is configured for this worklist", + fix=( + "record a gate pass with `warpline verify-record --commit " + "--kind test_pass`" + ), + ), + } +``` + +Also add a one-line comment to `enrich_blast` (`_blast.py:142-157`) recording the order-preservation invariant the alignment relies on: `# Order-preserving: changed[i]/affected[i] map 1:1 to result["changed"][i]/["affected"][i]; reverify's verification key-id alignment depends on this.` + +- [ ] **Step 4: Build the verification index + summary + advisory sort in `reverify_worklist`** + +In `src/warpline/commands.py`, inside `reverify_worklist` (`commands.py:745-876`): after `changed, affected = enrich_blast(store, repo, result)` (line ~777) and before `render_reverify_worklist`, add the index construction. Then pass it into render, attach the summary, and add the advisory presort. + +Add the import at the top of `commands.py`: `from warpline.verification import compose_verification_freshness` and `from warpline.git import is_ancestor, commits_between`. + +Insert before the `render_reverify_worklist(...)` call: + +```python + # Rung 2 Track B — verification freshness (advisory, never gates). + # Align entity_key_id to changed/affected ORDER. enrich_blast preserves + # the order of result["changed"]/result["affected"] (verified _blast.py:142-157), + # whose rows carry entity_key_id; the FROZEN {locator, sei} entity view never + # does. The positional alignment changed[i] <-> changed_key_ids[i] is the + # invariant render_reverify_worklist relies on to attach the block. + changed_key_ids: list[int | None] = [ + r.get("entity_key_id") if isinstance(r.get("entity_key_id"), int) else None + for r in result.get("changed", []) + ] + affected_key_ids: list[int | None] = [ + r.get("entity_key_id") if isinstance(r.get("entity_key_id"), int) else None + for r in result.get("affected", []) + ] + # Load ONLY the worklist's change commits (no full-table scan — push the + # entity filter into SQL) and group by key id; load verification events once. + worklist_key_ids = [k for k in (*changed_key_ids, *affected_key_ids) if k is not None] + verification_events = store.list_verification_events(repo) + local_source_configured = len(verification_events) > 0 + changes_by_key: dict[int, list[str]] = {} + for ce in store.list_change_events_for_key_ids(repo, worklist_key_ids): + kid = ce.get("entity_key_id") + if isinstance(kid, int): + sha = str(ce.get("commit_sha")) + bucket = changes_by_key.setdefault(kid, []) + # One entity can have several change_event rows for the SAME commit + # (the UNIQUE key is (repo, entity_key_id, commit_sha, path, change_kind), + # not commit_sha alone). Collapse adjacent duplicates (rows are + # oldest-first) so the covers() fan-out isn't wasted and + # entity_change_commits[-1] stays the true latest distinct commit. + if not bucket or bucket[-1] != sha: + bucket.append(sha) + + def _covers(verified_commit: str, change_commit: str) -> bool | None: + # NOTE the argument inversion: a change is COVERED by a verification + # iff the change commit is an ancestor-or-equal of the verified commit + # (the gate ran at/after the change). So covers(verified, change) maps + # to is_ancestor(ancestor=change_commit, descendant=verified_commit). + return is_ancestor(repo, change_commit, verified_commit) + + def _between(ancestor: str, descendant: str) -> int | None: + return commits_between(repo, ancestor, descendant) + + _verif_cache: dict[int, dict[str, Any]] = {} + + def verification_for(kid: int | None) -> dict[str, Any]: + # kid is None for an affected row that carried no entity_key_id; + # compose([], ...) honestly yields "unverified" (nothing to verify). + if kid is None: + return compose_verification_freshness([], verification_events, _covers, _between) + if kid not in _verif_cache: + _verif_cache[kid] = compose_verification_freshness( + changes_by_key.get(kid, []), + verification_events, + _covers, + _between, + ) + return _verif_cache[kid] +``` + +Change the `render_reverify_worklist(...)` call (currently `commands.py:780-786`) to pass the new kwargs: + +```python + items, work_seen, filigree_candidates = render_reverify_worklist( + changed=changed, + affected=affected, + completeness=completeness, + staleness=staleness, + work_client=work_client, + changed_key_ids=changed_key_ids, + affected_key_ids=affected_key_ids, + verification_for=verification_for, + ) +``` + +The render call attaches `item["verification"]` to every item but does NOT reorder them. The advisory stale-first presort goes **between the existing `apply_filters` (`commands.py:787`) and `apply_sort` (`commands.py:788`) calls** — NOT right after render. Pipeline order must be: `render` (780) → `apply_filters` (787) → **`[stale-first presort]`** → `apply_sort` (788) → **`[verification_summary]`** → `apply_group_by` → `apply_overflow` (820) → `apply_page` (823). + +Why exactly there (verified: `apply_sort` is Python's stable `sorted()` at `listing.py:332`, with a `sort_by=None` passthrough at `listing.py:306`): the LAST stable sort applied is the PRIMARY key. So the presort must run immediately *before* `apply_sort` — then `apply_sort` is primary and stale-first survives only as the secondary tiebreak within equal primary-key groups (and, when `sort_by` is None, the passthrough leaves the presort order intact). Placing it before `apply_filters` would (a) waste work sorting items filters then drop and (b) be a no-op once `apply_sort` re-sorts. Insert between the two existing statements: + +```python + items = apply_filters(items, tool="warpline_reverify_worklist_get", filters=filters) + # Advisory: stale-of-trust first. Stable presort run JUST BEFORE apply_sort + # so apply_sort stays the PRIMARY key (last stable sort wins) and stale-first + # is the secondary tiebreak within ties. Never reorders across the primary + # key; never removes an item. Relies on apply_sort being a stable sorted() + # (listing.py:332) with a sort_by=None passthrough (listing.py:306). + _state_rank = {"stale": 0, "unavailable": 1, "unverified": 2, "fresh": 3} + items.sort(key=lambda it: _state_rank.get(it["verification"]["state"], 3)) + items = apply_sort(items, tool="warpline_reverify_worklist_get", sort_by=sort_by, sort_order=sort_order) +``` + +(The `apply_filters`/`apply_sort` lines above are the EXISTING `commands.py:787-788` statements shown for context — insert only the three presort lines between them; match the real call signatures already in the file.) + +Then compute `verification_summary` from the **post-filter, post-sort, pre-page** item set (so a caller who filtered to one `path_prefix` gets counts for *their* scope, not the whole blast radius). Insert it **after `apply_sort` (788) and before `apply_overflow` (820)**, holding it in a variable for the `data` dict (built later, after paging): + +```python + # verification_summary reflects the post-filter, pre-page set (mirrors how + # completeness/staleness describe the requested set, not the current page). + verification_summary = { + "fresh": sum(1 for it in items if it["verification"]["state"] == "fresh"), + "stale": sum(1 for it in items if it["verification"]["state"] == "stale"), + "unverified": sum(1 for it in items if it["verification"]["state"] == "unverified"), + "unavailable": sum(1 for it in items if it["verification"]["state"] == "unavailable"), + "local_source_configured": local_source_configured, + } +``` + +Then add `verification_summary` to the `data = { ... }` dict (~`commands.py:824-834`), right after `"staleness": staleness,`: + +```python + data = { + "completeness": completeness, + "staleness": staleness, + "verification_summary": verification_summary, + "resolved": resolved, + # ... rest unchanged ... + } +``` + +> Why post-filter/pre-page (not pre-filter): `apply_filters` for this tool supports `path_prefix`/`priority`/`reason`/`has_sei`, all of which DROP items. Computing the summary before filters would report whole-repo counts even when the caller scoped to one path — a silent miscount. Computing it pre-page (before `apply_overflow`/`apply_page`) keeps it describing the full requested set rather than one page, consistent with `completeness`/`staleness`. The never-filter invariant is about *verification never removing an item*; caller filters are a separate, legitimate scoping the summary must honor. + +- [ ] **Step 5: Run the tests to verify they pass** + +Run: `uv run pytest tests/test_reverify_verification.py -v` +Expected: PASS (13 tests, incl. the mirrored mixed-depth secondary-sort test). + +- [ ] **Step 6: Run the full reverify + envelope suite** + +Run: `uv run pytest tests -k "reverify or envelope or render or worklist" -v && uv run mypy src/warpline` +Expected: PASS, mypy clean. (Catches any existing reverify test that asserts an exact item-dict shape and now needs the `verification` key — if a frozen-shape test breaks, that is a CONTRACT decision: the reverify worklist ITEM schema is additive here, so update that test to allow the new key; do NOT touch the frozen ENVELOPE `enrichment` vocab.) + +- [ ] **Step 7: Commit** + +```bash +git add src/warpline/reverify.py src/warpline/commands.py tests/test_reverify_verification.py +git commit -m "feat(reverify): advisory per-item verification block + summary + stale-first sort" +``` + +--- + +## Task 6: Golden vector `GV-VF-1` + honesty lock + +**Files:** +- Modify: `tests/contracts/test_golden_vectors.py` (add the GV-VF-1 test) +- Modify: `tests/fixtures/contracts/warpline/golden-vectors.json` (add the index entry) + +**Interfaces:** +- Consumes: `commands.reverify_worklist`, `commands.verify_record`, and the existing golden-vector test helpers (`_git_repo`, `_store`, `_seed_entity`, `_add_change`) in `test_golden_vectors.py`. +- Produces: `GV-VF-1` locking `fresh`/`stale`/`unverified` semantics, the unverified-when-no-source honesty, and the **never-filter** invariant — all asserted on the `data` block (NOT on `enrichment`, which would violate the closed vocab). + +- [ ] **Step 1: Inspect the existing helpers** + +Run: `grep -n "def _git_repo\|def _store\|def _seed_entity\|def _add_change" tests/contracts/test_golden_vectors.py` +Read those helpers so the new vector uses the real signatures (do not assume; e.g. `_seed_entity(store, repo_id, locator, sei)` and `_add_change(store, repo_id, key_id, path=...)` per the GV-HON-SEI example). + +- [ ] **Step 2: Write the GV-VF-1 test (it will fail until the assertions match real output, but the underlying feature from Tasks 1–5 already exists)** + +Add to `tests/contracts/test_golden_vectors.py`: + +```python +def test_gv_vf_1_reverify_verification_freshness_is_explained(tmp_path: Path) -> None: + """GV-VF-1: the reverify worklist carries an HONEST verification block. + + Locks: (a) unverified-when-no-source — every item reads ``unverified`` with a + ``disabled`` reason when no gate pass is recorded; (b) ``fresh`` once the + change is verified; (c) the never-filter invariant — recording verification + annotates/sorts but never removes an item; (d) verification rides the data + block, never the FROZEN enrichment vocab. + """ + + repo = _git_repo(tmp_path) + # One real commit so verify-record can resolve HEAD to an object SHA. + head = _commit_file(repo, "m.py", "v0\n") # see helper note below + with _store(repo) as store: + repo_id = store.ensure_repo(repo) + key_id = store.ensure_entity_key(repo_id, "python:function:m.py::f", None, head) + store.append_change_event( + repo_id=repo_id, entity_key_id=key_id, commit_sha=head, path="m.py", + change_kind="modified", actor="dev", changed_at="2026-06-25T08:00:00+00:00", + ) + + # (a) No verification recorded yet -> unverified + explained. + env = commands.reverify_worklist(repo, [key_id]) + summary = env["data"]["verification_summary"] + assert summary["local_source_configured"] is False + assert summary["unverified"] >= 1 + assert env["data"]["items"], "expected a non-empty worklist" + n_items = len(env["data"]["items"]) + item = env["data"]["items"][0] + assert item["verification"]["state"] == "unverified" + assert item["verification"]["reason"]["reason_class"] == "disabled" + assert item["verification"]["reason"]["cause"] and item["verification"]["reason"]["fix"] + # (d) verification is NOT in the frozen enrichment vocab. + assert "verification" not in env["enrichment"] + assert "verification" not in env["enrichment_reasons"] + + # (b) record a gate pass at HEAD -> fresh. + commands.verify_record(repo, commit=head, kind="test_pass", now="2026-06-25T10:00:00+00:00") + env2 = commands.reverify_worklist(repo, [key_id]) + assert env2["data"]["verification_summary"]["local_source_configured"] is True + assert env2["data"]["verification_summary"]["fresh"] >= 1 + assert env2["data"]["items"], "expected a non-empty worklist after verification" + assert any(i["reason"] == "changed" for i in env2["data"]["items"]) + fresh_item = next(i for i in env2["data"]["items"] if i["reason"] == "changed") + assert fresh_item["verification"]["state"] == "fresh" + assert fresh_item["verification"]["last_verified_commit"] == head + + # (c) never-filter is an IDENTITY invariant, not just cardinality: the exact + # SET of entities is unchanged by recording verification (count-equality alone + # would pass a buggy impl that drops one item and re-adds a different one). + assert len(env2["data"]["items"]) == n_items + before_locators = {i["entity"]["locator"] for i in env["data"]["items"]} + after_locators = {i["entity"]["locator"] for i in env2["data"]["items"]} + assert after_locators == before_locators + # Honesty meta preserved. + assert env2["meta"]["local_only"] is True + assert env2["meta"]["peer_side_effects"] == [] +``` + +**REQUIRED helper (the module does NOT have one):** `test_golden_vectors.py` has `_git_repo`/`_store`/`_seed_entity`/`_add_change` but NO `_commit_file`, and `_seed_entity`/`_add_change` use FAKE SHAs (e.g. `"c1"`) that `verify_record`'s `git rev-parse` cannot resolve. So this Step MUST add a real commit helper near the top of the module and seed via `ensure_entity_key`/`append_change_event` with the REAL HEAD SHA (as the test above does) — NOT `_seed_entity`: + +```python +def _commit_file(repo: Path, name: str, body: str) -> str: + (repo / name).write_text(body) + subprocess.run(["git", "add", "."], cwd=repo, check=True, capture_output=True) + subprocess.run(["git", "commit", "-q", "-m", f"touch {name}"], cwd=repo, check=True, capture_output=True) + return subprocess.run( + ["git", "rev-parse", "HEAD"], cwd=repo, check=True, text=True, capture_output=True + ).stdout.strip() +``` + +(Confirm `subprocess` and `Path` are imported in the module; `_git_repo` already creates the git repo, so call `_commit_file` on the repo it returns.) + +- [ ] **Step 3: Add the fixture index entry** + +In `tests/fixtures/contracts/warpline/golden-vectors.json`, add to the `vectors` array (match the existing entry shape — `id`, `seam`, `tool`, `assert`): + +```json +{ + "id": "GV-VF-1", + "seam": "warpline", + "tool": "warpline_reverify_worklist_get / warpline_verification_record", + "assert": "reverify carries an honest verification block on the DATA item (never the frozen enrichment vocab): no source -> every item unverified + disabled triple + local_source_configured false; record a gate pass at the change commit -> fresh + last_verified_commit set + local_source_configured true; recording verification never removes an item (never-filter); meta.local_only true / peer_side_effects []" +} +``` + +> If the fixture has a count field (e.g. a top-level `"count"` or a test asserting `len(vectors) == N`), bump it. Grep: `grep -rn "len(.*vectors\|count" tests/contracts/test_golden_vectors.py`. + +- [ ] **Step 4: Run the golden vectors** + +Run: `uv run pytest tests/contracts/test_golden_vectors.py -v` +Expected: PASS, including `GV-VF-1` and the existing vectors (and any "fixture index matches tests" meta-check). + +- [ ] **Step 5: Commit** + +```bash +git add tests/contracts/test_golden_vectors.py tests/fixtures/contracts/warpline/golden-vectors.json +git commit -m "test(contracts): GV-VF-1 locks verification-freshness honesty + never-filter" +``` + +--- + +## Task 7: Gate sweep + CHANGELOG + +**Files:** +- Modify: `CHANGELOG.md` +- Verify only: all gates green; no member diffs; mcp-smoke advertises the new tool. + +**Interfaces:** +- Consumes: everything from Tasks 1–6. +- Produces: a green release-candidate-equivalent gate run and a CHANGELOG entry. (No tag/release — owner-reserved.) + +- [ ] **Step 1: Full test suite** + +Run: `uv run pytest tests -v` +Expected: all PASS (the pre-existing baseline was 338 passed / 1 skipped; this adds ~45 new tests + GV-VF-1). 0 failures. If any PRE-EXISTING reverify/contract test fails, it will be because the additive per-item `verification` key or the new `verification_summary` data key changed a shape it asserts. Confirm the failure is an additive-key mismatch (NOT a frozen-envelope/enrichment-vocab break) and update that test to tolerate the additive key — e.g. `tests/test_reverify.py:25-30` asserts only the `enrichment` sub-dict (survives), so a break elsewhere means a test asserting whole-item-dict equality; relax it to the keys it cares about. Never weaken the closed `enrichment` vocab to make a test pass. + +- [ ] **Step 2: Lint + types** + +Run: `uv run ruff check . && uv run mypy src/warpline` +Expected: clean. + +- [ ] **Step 3: MCP smoke (new tool advertised)** + +Run: `uv run warpline mcp-smoke --repo . --json` +Expected: `ok: true`. Then confirm the new tool is listed: +Run: `uv run warpline mcp-smoke --repo . --json | python -c "import sys, json; d=json.load(sys.stdin); print('warpline_verification_record present:', any(c for c in d.get('checks', []) ))"` +(Or simpler — `uv run python -c "from warpline import mcp; print('warpline_verification_record' in {s['endorsed'] for s in mcp.TOOL_SPECS})"` → `True`.) + +- [ ] **Step 4: Dogfood eval** + +Run: `uv run warpline dogfood-eval --output /tmp/wl-dogfood.json --json` +Expected: `ready: True` (the existing dogfood cases must still pass — the additive `verification` block must not break parity or item counts; if a dogfood assertion counts item keys exactly, update the harness to tolerate the additive key, treating it like any other advisory enrichment). + +- [ ] **Step 5: Member-diff guard** + +Run: `bash scripts/maybe_check_member_diffs.sh` +Expected: 0 warpline-caused diffs in sibling repos (this change touches only warpline's own tree — no sibling files). + +- [ ] **Step 6: Update CHANGELOG** + +In `CHANGELOG.md`, add an `[Unreleased]` (or `1.3.0`) section above `[1.2.0]`: + +```markdown +## [Unreleased] + +### Added +- **Verification freshness (Rung 2, Track B).** The reverify worklist now carries + an advisory per-item `verification` block (`fresh` / `stale` / `unverified` / + `unavailable`) with a trust-decay signal, plus a `verification_summary` rollup — + answering "what changed since it was last proven good." Sourced from warpline's + own gate result via a new mutating verb `verify-record` (CLI) / + `warpline_verification_record` (MCP), the 2nd local-only mutating tool. Advisory + and enrich-only: it annotates and re-sorts (stale-of-trust first) but NEVER + filters an item, and NEVER gates. Sibling-sourced verification (wardline/ + filigree/legis) remains honest-absent RESERVED. New schema v4 + (`verification_events`); golden vector `GV-VF-1`. The frozen `warpline..v1` + envelope and the closed 6-key enrichment vocab are untouched (verification rides + the reverify-item schema, not the enrichment vocab). +``` + +- [ ] **Step 7: Confirm a clean tree + final commit** + +```bash +git add CHANGELOG.md +git commit -m "docs(changelog): verification freshness (Rung 2 Track B)" +git status --short # expect: clean +``` + +- [ ] **Step 8: Run the release-candidate gate end-to-end (read-only confidence check)** + +Run: `bash scripts/check_release_candidate.sh` +Expected: exits 0 (clean tree, member-diffs, spike, dogfood, productization, ruff, mypy, pytest all green). This is the merge-readiness proof. (Do NOT tag or release — that is owner-directed.) + +--- + +## Self-Review (completed by plan author) + +**Spec coverage:** +- v4 migration + accessors → Task 1 ✅ +- `verify-record` verb (CLI+MCP, ref-resolution, errors, tool metadata) → Task 4 ✅ +- pure `compose_verification_freshness` (fresh/stale/unverified/unavailable + reason triples) → Task 3 (+ git helpers Task 2) ✅ +- reverify integration (per-item block + summary + advisory sort, never filter) → Task 5 ✅ +- `GV-VF-1` + honesty lock → Task 6 ✅ +- gate sweep → Task 7 ✅ +- Non-goals respected: sibling sources stay RESERVED/honest-absent (not implemented); `verification` NOT promoted to the frozen envelope vocab (rides data/item field); no gating/filtering (never-filter test in Tasks 5 & 6). ✅ + +**Type consistency:** `compose_verification_freshness(entity_change_commits, verification_events, covers, commits_between)` is referenced identically in Task 3 (def), Task 5 (`reverify.py` default + `commands.py` call). `verification_for: Callable[[int | None], dict]` consistent between `reverify.py` and `commands.py`. The block keys (`state`/`last_verified_at`/`last_verified_commit`/`decay.commits_behind`/`reason`) are identical across Tasks 3, 5, 6. `record_verification_event` / `list_verification_events` signatures identical across Tasks 1, 4, 5. + +**Plan-review v1 (4-dimension panel + synthesis, 2026-06-25) — all blocker/high/medium findings resolved into the plan:** +- BLOCKER — `test_store_migrations.py:48` pins `HIGHEST_KNOWN_VERSION == 3` → Task 1 Step 6b updates it to `== 4` (required). +- BLOCKER — 2nd mutating tool breaks `test_warpline_contract_fixtures.py:54-55` (`mutates is is_capture`) + the static `mcp-tool-inventory.json` → Task 4 Step 5b generalizes the assertion + regenerates the inventory (required). +- BLOCKER — import-time `assert_inputschema_consumed()` (`mcp.py:574`) crashes all tools if the new tool is missing from `_HANDLER_CONSUMES`/`_KNOWN_FASTFOLLOW_DEAD` → Task 4 Step 5 makes BOTH entries required, not conditional. +- BLOCKER — `GV-VF-1` referenced a non-existent `_commit_file` helper + fake SHAs → Task 6 Step 2 makes the real-commit helper required and seeds with the real HEAD SHA + adds items guards. +- HIGH — `verification_summary` placement was self-contradictory → Task 5 Step 4 fixes it to post-filter/post-sort/pre-page, with a dedicated filter test. +- MEDIUM — wrong error code for blank `kind` → `MissingRequiredFieldError` (Task 4 Step 3). O(N) full-table scan → `list_change_events_for_key_ids` (Tasks 1 + 5). Advisory-sort/sort_by interaction → documented as secondary tiebreak + stale-before-fresh test (Task 5). `verified_at` lexical-sort bug → `datetime()`-normalized ORDER BY + offset test (Task 1). Missing `unavailable` integration + idempotency-across-timestamps coverage → added (Tasks 4 + 5). +- LOW — reuse `_now()` (Task 4); CLI error-path test (Task 4); reconcile spec accessor names (spec updated); document `enrich_blast` order invariant (Task 5 Step 3). + +**Plan-review v2 (re-review, 2026-06-25) — 0 blockers; all 4 HIGH + 5 MEDIUM resolved:** +- HIGH — advisory-presort placement → pinned BETWEEN `apply_filters` (787) and `apply_sort` (788) so `apply_sort` stays primary (Task 5 Step 4). +- HIGH — stale-before-fresh test could pass vacuously → added `test_stale_first_is_secondary_to_an_explicit_sort` (mixed-depth, depth-primary; mirrors existing downstream setup) (Task 5). +- HIGH — post-filter summary test only covered the zero case → added `test_verification_summary_counts_only_filtered_subset` (2 entities, asserts count == 1) (Task 5). +- HIGH — `list_change_events_for_key_ids` oldest-first ordering unasserted → added `test_list_change_events_for_key_ids_is_oldest_first` (Task 1). +- MEDIUM — Step 6b under-specified → now names every stale-v3 site (lines 45-46, 48, 131, 166-175) (Task 1). Duplicate per-entity SHAs → adjacent-dedup in `changes_by_key` (Task 5). `covers`/`is_ancestor` arg-order → documented at `_covers` + `test_fresh_when_verified_at_a_later_commit` (asymmetric, catches a swap). Partial-unavailable gap → `test_unavailable_when_latest_undetermined_even_if_earlier_covered` (Task 3). Squash/rebase + detached-HEAD → `test_unavailable_when_change_commit_no_longer_exists` (Task 5) + `test_verify_record_in_detached_head` (Task 4). +- LOW — idempotency now via `record_verification_event -> bool` (O(1), race-free) (Task 1/4); GV-VF-1 never-filter strengthened to set-identity (Task 6); `test_mcp_module_imports` smoke test first (Task 4); monkeypatch import-order + `next()`-guard notes (Task 5 Step 2); stale-path `[:-1]` micro-opt (Task 3). + +Confirmed by the v2 panel against source: the v1 blocker fixes are all correct (`test_store_migrations.py:48` really pins `==3`; `MissingRequiredFieldError` at `errors.py:70-73`; `_HANDLER_CONSUMES`/`_KNOWN_FASTFOLLOW_DEAD` both exist; `has_sei` filter valid at `listing.py:271-272`; `apply_sort` stable at `listing.py:332` with `sort_by=None` passthrough at `listing.py:306`). No frozen-contract violations; verification never enters the 6-key vocab. + +**Residual confirm-points (LOW, the implementer verifies against source — not blocking):** +1. The exact `filters=` dict shape for the post-filter tests (Task 5; `has_sei` key verified, shape to confirm from an existing reverify filter test). +2. The `mcp-tool-inventory.json` regeneration mechanism (script vs hand-edit) — Task 4 Step 5b. +3. The downstream/affected-item edge-snapshot setup to mirror for `test_stale_first_is_secondary_to_an_explicit_sort` — Task 5 (grep existing tests for `capture_snapshot_atomic`). diff --git a/docs/superpowers/specs/2026-06-23-verification-freshness-design.md b/docs/superpowers/specs/2026-06-23-verification-freshness-design.md index 0cabbfe..b1fb08c 100644 --- a/docs/superpowers/specs/2026-06-23-verification-freshness-design.md +++ b/docs/superpowers/specs/2026-06-23-verification-freshness-design.md @@ -75,8 +75,12 @@ CREATE TABLE IF NOT EXISTS verification_events ( ); ``` -Accessors: `record_verification_event(*, repo_id, commit_sha, kind, verified_at, actor, source) -> int` -and `verification_events_for_repo(repo_id) -> list[dict]` (ordered by `verified_at`). +Accessors (names/return types match the existing `change_events` convention — +reconciled with the implementation plan): `record_verification_event(*, repo_id, +commit_sha, kind, verified_at, actor, source="warpline") -> bool` (idempotent +`INSERT OR IGNORE`; returns whether a new row was inserted) and `list_verification_events(repo: +Path) -> list[dict]` (ordered oldest-first by the normalized `verified_at` instant, +like `list_change_events`). ### 2. Write path — `verify-record` verb (CLI + MCP) diff --git a/pyproject.toml b/pyproject.toml index c0ea4a9..748640f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,7 @@ dependencies = [] [dependency-groups] dev = [ + "jsonschema>=4.26.0", "mypy>=1.18.2", "pytest>=9.0.1", "ruff>=0.14.8", diff --git a/site/src/pages/index.astro b/site/src/pages/index.astro index 29d2b70..96227f4 100644 --- a/site/src/pages/index.astro +++ b/site/src/pages/index.astro @@ -139,7 +139,7 @@ const LIMITS = [ enhancement you can act on or ignore — never a verdict you must clear. Warpline consumes Loomweave SEI and never mints identity; it feeds advisory change-impact facts to governance-style surfaces - (Legis / Charter), which run their own policy. Warpline supplies the facts + (Legis / Plainweave), which run their own policy. Warpline supplies the facts and never makes the call. @@ -301,6 +301,18 @@ const LIMITS = [ Warpline is production-stable for its own surface, and scrupulously honest about what is wired and what is not. Read these before you wire it in. + + A blast-radius answer and the re-verify worklist are advisory + deconfliction — they tell an agent what a change may touch and what to + recheck, never who may make a change, whether one is allowed, or whether a record + may be trusted. Warpline is not an access-control gate, an authorization + control, or a compliance boundary; its change history lives under + .weft/warpline/ and is not encrypted, sandboxed, or hardened beyond + ordinary filesystem permissions. Any tamper-evident governance record lives in + Legis, dialed up per repo. Warpline is deconfliction-first, not + security: do not use it for secure, regulated, confidential, or business-sensitive + data. +
    {LIMITS.map((l) =>
  • $1')}>
  • )}
diff --git a/solution-architecture/06-descoped-and-deferred.md b/solution-architecture/06-descoped-and-deferred.md index 3eb808f..2102be1 100644 --- a/solution-architecture/06-descoped-and-deferred.md +++ b/solution-architecture/06-descoped-and-deferred.md @@ -5,8 +5,8 @@ assembly, are a different register — they live in the `99-` gate report.) | # | Item | Disposition | Reactivation trigger | |---|------|-------------|----------------------| -| D-01 | Member-side consumer wiring (loomweave `high_churn`/`recently_changed` lighting up, wardline scoped re-scan, legis gate scope, charter re-verify pull) | DEFERRED — designed at the seam level in `15-`, built only in each member's own tracker | launch cutover lands (lifts CON-TEC-02) AND spike returns go AND owner admits per §7 | -| D-02 | Requirements-side impact analysis | DESCOPED — Charter's domain (doctrine §2) | never (re-open only via a doctrine change, which is owner-reserved) | +| D-01 | Member-side consumer wiring (loomweave `high_churn`/`recently_changed` lighting up, wardline scoped re-scan, legis gate scope, plainweave re-verify pull) | DEFERRED — designed at the seam level in `15-`, built only in each member's own tracker | launch cutover lands (lifts CON-TEC-02) AND spike returns go AND owner admits per §7 | +| D-02 | Requirements-side impact analysis | DESCOPED — Plainweave's domain (doctrine §2) | never (re-open only via a doctrine change, which is owner-reserved) | | D-03 | Change execution / rollback provenance | DESCOPED — Shuttle's sketched gap | never within Warpline | | D-04 | Actor identity verification ("is this actor string true?") | DESCOPED — Tabard (roadmap Later) | Tabard ships; Warpline then consumes, not implements | | D-05 | Cross-host / multi-machine history | DEFERRED | cross-host federation becomes real (roadmap Later) | diff --git a/src/warpline/_attest.py b/src/warpline/_attest.py new file mode 100644 index 0000000..d980586 --- /dev/null +++ b/src/warpline/_attest.py @@ -0,0 +1,266 @@ +"""Risk-as-verification consumer of a wardline-attest-2 bundle (Rung 2) — pure. + +This is warpline's consumer of wardline's ``wardline-attest-2`` evidence bundle. +It closes the ``verification_source_absent`` gap that D1's completeness consumer +left open: an impact-complete worklist whose every entity is *attested clean at +its current body* can finally read **proven-good**, instead of merely "not +partial". + +Posture (mirrors ``_completeness`` / ``verification``): pure, enrich-only — it +imports only ``typing`` + ``warpline.listing.reason`` + ``warpline._completeness``; +no store, no git, no I/O. The current commit and the per-SEI current content_hash +are INJECTED (a ``content_hash_for_sei`` callable), so the equality logic is +testable without a live loomweave. + +The check is MECHANICAL, exactly as the contract names it: + + * the bundle is a PUSHED, UNTRUSTED payload — its HMAC signature is NOT verified + (warpline does not hold wardline's shared project key; see the attest threat + model). A proven-good verdict is therefore an *echo of wardline's authority, + mechanically confirmed current*, NOT a warpline-minted "clean" and NOT a + cryptographic proof. This ceiling is stated in every proven verdict + (``signature_verified: false``, ``authority: "wardline"``). + * per affected entity (keyed on SEI): the matching bundle boundary must carry a + ``"clean"`` verdict AND its ``content_hash`` must equal the entity's CURRENT + content_hash (entity-body equality — both sides read loomweave's per-entity + hash, confirmed byte-identical), AND the bundle's ``commit`` must equal the + worklist's commit AND not be ``dirty`` (so the commit truthfully pins). + +Honesty edges (every one degrades to ``unavailable`` with a machine reason, NEVER +silent-clean): a missing bundle, an unknown schema, a dirty tree, a null commit, +``sei_source: "unavailable"`` (nothing keyable), a null per-boundary sei / +content_hash, a non-``clean`` verdict, or ANY affected entity left unmatched +(all-or-nothing — one miss sinks the whole worklist's proven-good). +""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +from warpline._completeness import completeness_risk +from warpline.listing import reason + +ATTEST_SCHEMA = "wardline-attest-2" +# The wardline boundary verdict vocab is three-valued (defect|clean|unknown); only +# "clean" is proven-good. "unknown" is fail-closed (under-scanned), NEVER clean. +_CLEAN_VERDICT = "clean" + +# Closed machine-code vocabulary for the risk-as-verification verdict. +ATTEST_RISK_CODES = frozenset( + { + "attested_clean", # PROVEN: every affected entity attested clean at current body + "verification_source_absent", # complete, but no attest bundle supplied + "attestation_schema_unknown", # bundle is not wardline-attest-2 + "attestation_dirty", # bundle attested a dirty tree — commit does not pin + "attestation_no_commit", # bundle commit is null (non-git) — cannot pin + "attestation_commit_mismatch", # bundle commit != the worklist's commit + "attestation_unkeyed", # sei_source unavailable — no boundary is SEI-keyed + "attestation_incomplete", # >=1 affected entity not attested-clean-at-current-body + } +) + + +class AttestBundleError(ValueError): + """A bundle that is structurally unusable (not a dict / no payload).""" + + +def parse_attest_bundle(bundle: Any) -> dict[str, Any]: + """Normalize a wardline-attest-2 bundle into ``{schema, commit, dirty, + sei_source, by_sei}`` where ``by_sei`` maps each SEI-carrying boundary's sei + to its boundary dict. Raises :class:`AttestBundleError` on a structurally + unusable bundle; an UNKNOWN schema is NOT raised here (it is an honest + ``attestation_schema_unknown`` verdict downstream).""" + + if not isinstance(bundle, dict): + raise AttestBundleError("attestation bundle must be a JSON object") + payload = bundle.get("payload") + if not isinstance(payload, dict): + raise AttestBundleError("attestation bundle payload must be a JSON object") + boundaries = payload.get("boundaries") + by_sei: dict[str, dict[str, Any]] = {} + if isinstance(boundaries, list): + for b in boundaries: + if isinstance(b, dict): + sei = b.get("sei") + if isinstance(sei, str) and sei: + by_sei[sei] = b + return { + "schema": bundle.get("schema"), + "commit": payload.get("commit"), + "dirty": bool(payload.get("dirty")), + "sei_source": payload.get("sei_source"), + "by_sei": by_sei, + } + + +def _entity_attested( + boundary: dict[str, Any] | None, + *, + current_content_hash: str | None, +) -> bool: + """Mechanical per-entity equality: a matching boundary with a ``clean`` verdict + whose ``content_hash`` equals the entity's current content_hash. A null + boundary / sei / content_hash, or a non-clean verdict, is NOT attested.""" + + if boundary is None: + return False + if boundary.get("verdict") != _CLEAN_VERDICT: + return False + attested_hash = boundary.get("content_hash") + if not isinstance(attested_hash, str) or not attested_hash: + return False + if not isinstance(current_content_hash, str) or not current_content_hash: + return False + return attested_hash == current_content_hash + + +def _unavailable(reason_code: str, *, cause: str, fix: str) -> dict[str, Any]: + return { + "risk": "unavailable", + "reason_code": reason_code, + "reason": reason("disabled", cause=cause, fix=fix), + } + + +def worklist_risk( + impact_completeness: dict[str, Any] | None, + *, + affected_seis: list[str], + bundle: Any = None, + current_commit: str | None = None, + content_hash_for_sei: Callable[[str], str | None] | None = None, +) -> dict[str, Any]: + """The full risk-as-verification verdict for a worklist. + + Layered on D1's completeness gate: a worklist whose impact set is not + genuinely ``complete`` can NEVER read proven-good (you cannot prove a + narrowed/partial scope good), so that case returns + :func:`completeness_risk`'s ``unavailable`` verdict unchanged. Only on a + ``complete`` worklist does the attest bundle decide: + + * no bundle -> ``unavailable(verification_source_absent)`` (the gap) + * bundle present, every affected entity attested-clean-at-current-body + -> ``proven`` (``attested_clean``) — echo of wardline + * any honesty edge / unmatched entity + -> ``unavailable()`` + + ``affected_seis`` is every entity the worklist would have you re-verify; the + proven-good verdict is ALL-OR-NOTHING across them. ``content_hash_for_sei`` + yields the entity's CURRENT content_hash (loomweave) or None when unresolvable. + Never returns ``clean`` on warpline's own authority. + """ + + # 1. Completeness gate first (D1). Absent / partial / unknown can never be proven. + if not isinstance(impact_completeness, dict) or impact_completeness.get("status") != "complete": + return completeness_risk(impact_completeness) + + # 2. complete, but no attestation supplied -> the honest Rung-2 gap. + if bundle is None: + return _unavailable( + "verification_source_absent", + cause=( + "impact completeness is 'complete', but no wardline-attest-2 bundle was " + "supplied to prove the change good" + ), + fix=( + "hand warpline a wardline-attest-2 bundle for this commit " + "(`wardline attest . --out bundle.json`) so the change can read proven-good" + ), + ) + + try: + parsed = parse_attest_bundle(bundle) + except AttestBundleError as exc: + return _unavailable( + "attestation_schema_unknown", + cause=f"the supplied attestation bundle is structurally unusable: {exc}", + fix="supply a well-formed wardline-attest-2 bundle", + ) + + # 3. Bundle-level honesty edges — each blocks a proven-good verdict. + if parsed["schema"] != ATTEST_SCHEMA: + return _unavailable( + "attestation_schema_unknown", + cause=f"attestation schema is {parsed['schema']!r}, not {ATTEST_SCHEMA!r}", + fix=f"supply a {ATTEST_SCHEMA} bundle (a newer/older attest schema is not honored)", + ) + if parsed["dirty"]: + return _unavailable( + "attestation_dirty", + cause="the attestation was built over a DIRTY working tree, so its commit does " + "not truthfully pin the attested source", + fix="re-attest a clean (committed) tree so the bundle's commit pins the body", + ) + if not isinstance(parsed["commit"], str) or not parsed["commit"]: + return _unavailable( + "attestation_no_commit", + cause="the attestation has no commit (non-git tree), so it cannot pin a source", + fix="attest a committed git tree so the bundle records a pinning commit", + ) + if current_commit is not None and parsed["commit"] != current_commit: + return _unavailable( + "attestation_commit_mismatch", + cause=f"the attestation pins commit {str(parsed['commit'])[:8]}, not the worklist's " + f"commit {str(current_commit)[:8]}", + fix="attest the same commit the worklist describes, then re-consult", + ) + if parsed["sei_source"] == "unavailable": + return _unavailable( + "attestation_unkeyed", + cause="the attestation resolved no SEIs (sei_source='unavailable'), so no boundary " + "is keyable to an affected entity", + fix="build the attestation with loomweave wired (--loomweave-url) so boundaries " + "carry SEIs", + ) + + # 4. Per-entity mechanical match. ALL-OR-NOTHING across the worklist. + # + # An EMPTY affected set cannot be proven: the all-or-nothing loop below is + # vacuously satisfied (zero unmatched), but no affected entity was actually + # attested. A complete worklist with no SEI-keyed entity (e.g. an install / + # backfill run while loomweave was unavailable) is UNKEYED, not proven-good — + # fail closed rather than mint a vacuous proven verdict with matched/affected 0. + if not affected_seis: + return _unavailable( + "attestation_unkeyed", + cause="the worklist's impact set carries no SEI-keyed entity, so no affected " + "entity can be matched against the attestation (an unkeyed/backfill worklist " + "cannot be proven good)", + fix="re-index with loomweave wired so the worklist's entities carry SEIs, then " + "re-consult — an empty/unkeyed affected set is never proven-good", + ) + + chash = content_hash_for_sei or (lambda _sei: None) + by_sei = parsed["by_sei"] + unmatched: list[str] = [] + for sei in affected_seis: + if not _entity_attested(by_sei.get(sei), current_content_hash=chash(sei)): + unmatched.append(sei) + + if unmatched: + shown = ", ".join(unmatched[:5]) + (" …" if len(unmatched) > 5 else "") + return _unavailable( + "attestation_incomplete", + cause=f"{len(unmatched)}/{len(affected_seis)} affected entit" + f"{'y is' if len(unmatched) == 1 else 'ies are'} not attested clean at their " + f"current body (missing boundary, non-clean verdict, or content_hash drift): {shown}", + fix="re-run wardline's gate and re-attest at this commit so every affected entity " + "carries a clean, body-matching attestation", + ) + + # 5. PROVEN-GOOD. An echo of wardline's clean attestation, mechanically confirmed + # current — NOT a warpline-minted clean, and the HMAC is NOT verified here. + return { + "risk": "proven", + "reason_code": "attested_clean", + "authority": "wardline", + "source": ATTEST_SCHEMA, + "signature_verified": False, + "basis": "mechanical (commit, content_hash) equality vs wardline's attestation; " + "HMAC signature NOT verified by warpline", + "commit": parsed["commit"], + "matched": len(affected_seis), + "affected": len(affected_seis), + "reason": reason("clean"), + } diff --git a/src/warpline/_blast.py b/src/warpline/_blast.py index 1b7d4ef..f0bd165 100644 --- a/src/warpline/_blast.py +++ b/src/warpline/_blast.py @@ -139,6 +139,8 @@ def enrich_blast( def view(key_id: Any) -> dict[str, Any]: return entity_view(key_rows.get(int(key_id)) if isinstance(key_id, int) else None) + # Order-preserving: changed[i]/affected[i] map 1:1 to result["changed"][i]/["affected"][i]; + # reverify's verification key-id alignment depends on this. changed = [{"entity": view(row.get("entity_key_id"))} for row in result.get("changed", [])] affected = [] for row in result.get("affected", []): diff --git a/src/warpline/_completeness.py b/src/warpline/_completeness.py new file mode 100644 index 0000000..e2670f3 --- /dev/null +++ b/src/warpline/_completeness.py @@ -0,0 +1,225 @@ +"""Impact-completeness self-assessment (federation D1) — pure, enrich-only. + +Mirrors ``_enrichment`` / ``verification`` in posture: imports only ``typing`` + +``warpline._enrichment.is_stale`` + ``warpline.listing.reason`` — the structural +proof that this module cannot gate, mirror a sibling, open a store, or perform +I/O. It maps signals warpline has ALREADY computed (the snapshot edge-completeness +enum, the staleness block, the resolve-join miss-set, and a depth-cap flag from +the blast traversal) into: + + * ``compute_impact_completeness`` — the producer side: the + ``data.impact_completeness`` object carried on every ``reverify_worklist.v1``. + wardline mirrors this object VERBATIM into its own ``producer_completeness`` + scope-honesty field; the shape here is the federation coordination point. + + * ``completeness_risk`` — the consumer side (risk-as-verification): given the + ``impact_completeness`` of a (pushed, untrusted) worklist, a missing or + non-``complete`` assessment CANNOT be read as proven-good, so warpline + degrades to ``risk=unavailable`` with an explicit machine reason. It never + returns ``clean`` — warpline declares no change verified/allowed. + +Honesty doctrine: ``status="complete"`` is emitted ONLY when the impact set is +genuinely exhaustive (a positively-fresh FULL graph, no depth cap, zero +unresolved entities). Anything else is ``partial``; when coverage cannot be +assessed at all (no graph) it is ``unknown``. Never ``complete`` on a guess. + +The contract object lives at ``data.impact_completeness`` (NOT ``data.completeness``, +which is the FROZEN raw snapshot-completeness STRING enum on ``v1``). This is an +additive ``v1`` field — raw signal (string) vs. derived assessment (object). +""" + +from __future__ import annotations + +from typing import Any + +from warpline._enrichment import is_stale +from warpline.listing import reason + +# Closed status vocabulary for impact_completeness.status. +IMPACT_COMPLETENESS_STATUS = frozenset({"complete", "partial", "unknown"}) + +# Closed machine-code vocabulary for impact_completeness.reasons. Federation +# consumers (wardline) switch on these codes, never on prose. +COMPLETENESS_REASON_CODES = frozenset( + { + "graph_stale", # snapshot is behind HEAD + "graph_freshness_unknown", # commits_behind could not be computed + "partial_snapshot", # snapshot itself is a DELTA (capped/failed at capture) + "depth_capped", # blast traversal truncated at the depth horizon + "unresolved_entities", # changed refs that did not map to graph nodes + "no_snapshot", # no edge snapshot exists at all + "snapshot_skipped", # snapshot capture was SKIPPED (loomweave absent) + } +) + +# Closed machine-code vocabulary for the consumer-side risk gate. +COMPLETENESS_RISK_CODES = frozenset( + { + "completeness_not_declared", # worklist carried no impact_completeness + "completeness_partial", # declared, but status != complete + "verification_source_absent", # complete, but no proven-good source wired + } +) + +# Snapshot edge-completeness enum (the raw data.completeness STRING) for which an +# edge graph genuinely exists and downstream coverage is therefore assessable. +_GRAPH_PRESENT = frozenset({"FULL", "DELTA"}) + + +def compute_impact_completeness( + *, + as_of: str, + completeness: str, + staleness: dict[str, Any], + unresolved: list[Any], + depth_capped: bool, +) -> dict[str, Any]: + """Build the ``impact_completeness`` object. See module docstring for the + honesty contract. ``as_of`` is the producer generation timestamp (ISO 8601) — + the staleness axis of the assessment, which wardline echoes as an unverified + proxy; ``completeness`` is the raw snapshot edge-completeness enum + (FULL/DELTA/NO_SNAPSHOT/SKIPPED); ``staleness`` is the worklist staleness + block; ``unresolved`` is the resolve-join miss-set; ``depth_capped`` is the + blast-traversal truncation flag. + + The object carries BOTH axes of the derived assessment in one place — the + staleness axis (``as_of`` + ``graph_fresh`` + ``graph_ref``) and the + completeness axis (``status`` + ``depth_capped`` + ``unresolved_count``) — + so a single federation field declares warpline's completeness AND staleness. + """ + + unresolved_count = len(unresolved) + graph_ref = staleness.get("snapshot_commit") + reasons: list[str] = [] + depth_capped = bool(depth_capped) + + if completeness not in _GRAPH_PRESENT: + # No edge graph captured -> downstream coverage cannot be assessed. This + # is "unknown" (we cannot tell), never "partial" (which implies we mapped + # some-but-not-all of a known graph) and never "complete". + reasons.append("no_snapshot" if completeness == "NO_SNAPSHOT" else "snapshot_skipped") + if unresolved_count: + reasons.append("unresolved_entities") + return { + "status": "unknown", + "as_of": as_of, + "graph_fresh": False, + "graph_ref": graph_ref, + "depth_capped": depth_capped, + "unresolved_count": unresolved_count, + "reasons": reasons, + } + + # A graph exists (FULL or DELTA). Defer to the canonical staleness notion + # (is_stale): behind > 0 is stale, and an uncomputable distance (behind is + # None, snapshot commit present) is treated as not-proven-fresh — both block + # "complete". We keep the finer reason granularity (definitely-stale vs + # freshness-unknown) for the consumer. + behind = staleness.get("commits_behind") + # A claimed FULL/DELTA graph with no commit ref is incoherent input; refuse to + # call it fresh (defensive — never claim complete without a real graph_ref). + graph_fresh = graph_ref is not None and not is_stale(staleness) + if not graph_fresh: + if isinstance(behind, int) and behind > 0: + reasons.append("graph_stale") + else: + reasons.append("graph_freshness_unknown") + if completeness == "DELTA": + reasons.append("partial_snapshot") + if depth_capped: + reasons.append("depth_capped") + if unresolved_count: + reasons.append("unresolved_entities") + + exhaustive = ( + completeness == "FULL" + and graph_fresh + and not depth_capped + and unresolved_count == 0 + ) + return { + "status": "complete" if exhaustive else "partial", + "as_of": as_of, + "graph_fresh": graph_fresh, + "graph_ref": graph_ref, + "depth_capped": depth_capped, + "unresolved_count": unresolved_count, + "reasons": reasons, + } + + +def completeness_risk(impact: dict[str, Any] | None) -> dict[str, Any]: + """Consumer-side risk-as-verification gate over a worklist's impact + completeness. + + A worklist is a PUSHED, UNTRUSTED payload; its self-declared completeness is + an unverified producer claim. When that claim is absent or anything other than + ``complete``, the impact set is narrowed/partial and CANNOT be treated as + authoritative, so warpline reports ``risk=unavailable`` with an explicit + machine reason. Even a ``complete`` claim is not, by itself, a proven-good + verdict — that requires the risk-as-verification source (the wardline attest + bundle), which is out of D1 scope. This function therefore NEVER returns + ``clean`` / ``allowed``. + + Returns ``{risk, reason_code, reason}`` where ``reason`` is a canonical + ``listing.reason()`` weft triple (non-clean: carries cause + fix). + """ + + if not isinstance(impact, dict) or "status" not in impact: + return { + "risk": "unavailable", + "reason_code": "completeness_not_declared", + "reason": reason( + "disabled", + cause=( + "the worklist declares no impact-completeness assessment " + "(data.impact_completeness is absent), so its coverage is " + "unknown and the change cannot be treated as exhaustively analysed" + ), + fix=( + "regenerate the worklist with a warpline that emits " + "data.impact_completeness; until then treat the change as unverified" + ), + ), + } + + status = impact.get("status") + if status != "complete": + codes = impact.get("reasons") or [] + return { + "risk": "unavailable", + "reason_code": "completeness_partial", + "reason": reason( + "partial", + cause=( + f"impact completeness is {status!r}, not 'complete' (reasons: " + f"{list(codes)}); the impact set is narrowed/partial and is not " + "authoritative" + ), + fix=( + "resolve the partial-coverage reasons (recapture a fresh graph, " + "raise traversal depth, resolve unmapped entities) and regenerate " + "the worklist" + ), + ), + } + + # status == complete: the completeness gate does not degrade, but warpline + # still never declares clean. The proven-good verdict is owned by the + # (out-of-scope) risk-as-verification consumer; until it is wired this is a + # gap, not a pass. + return { + "risk": "unavailable", + "reason_code": "verification_source_absent", + "reason": reason( + "disabled", + cause=( + "impact completeness is 'complete', but no risk-as-verification " + "source (wardline attest bundle) is wired to prove the change good" + ), + fix=( + "wire the wardline attestation consumer (Rung 2) so a complete impact " + "set can be turned into a verified verdict" + ), + ), + } diff --git a/src/warpline/_enrichment.py b/src/warpline/_enrichment.py index d40e8c5..67581cc 100644 --- a/src/warpline/_enrichment.py +++ b/src/warpline/_enrichment.py @@ -103,15 +103,15 @@ def requirements_reason() -> dict[str, Any]: ) -def sei_reason(sei_state: str) -> dict[str, Any] | None: +def sei_reason(sei_state: str) -> dict[str, Any]: """Map a closed ``enrichment.sei`` scalar to its explanatory weft-reason triple. ``present`` is an earned ``clean``; ``absent`` (peer present, the changed locator never resolved to an SEI) is ``unresolved_input``; ``unavailable`` (the Loomweave SEI authority was unreachable, e.g. mid-capture) is - ``unreachable``. Returns ``None`` for any value outside the closed vocab so a - caller never attaches a triple it cannot explain. Reuses the canonical 11 — - no new reason_class. + ``unreachable``. Raises ValueError for any value outside the closed vocab so a + caller never attaches a triple it cannot explain (and so the call sites need no + narrowing assert). Reuses the canonical 11 — no new reason_class. """ if sei_state == "present": @@ -140,4 +140,7 @@ def sei_reason(sei_state: str) -> dict[str, Any] | None: "recapture/re-query so SEIs can be resolved" ), ) - return None + raise ValueError( + f"sei_state {sei_state!r} is outside the closed enrichment.sei vocab " + "(present|absent|unavailable)" + ) diff --git a/src/warpline/cli.py b/src/warpline/cli.py index 475f8a3..8f5e82b 100644 --- a/src/warpline/cli.py +++ b/src/warpline/cli.py @@ -329,6 +329,17 @@ def build_parser() -> argparse.ArgumentParser: required=True, ) reverify_parser.add_argument("--depth", type=int, default=2) + reverify_parser.add_argument( + "--attest-bundle", + type=Path, + default=None, + help=( + "path to a PUSHED wardline-attest-2 bundle (JSON). When supplied, a " + "complete worklist whose every affected entity is attested clean at its " + "current body reads proven-good in data.risk_verification (mechanical " + "(commit, content_hash) equality; HMAC signature NOT verified)." + ), + ) reverify_parser.add_argument("--json", action="store_true") capture_snapshot_parser = sub.add_parser("capture-snapshot") @@ -337,6 +348,13 @@ def build_parser() -> argparse.ArgumentParser: capture_snapshot_parser.add_argument("--loomweave-command", default="loomweave") capture_snapshot_parser.add_argument("--json", action="store_true") + verify_record_parser = sub.add_parser("verify-record") + verify_record_parser.add_argument("--repo", type=Path, default=Path(".")) + verify_record_parser.add_argument("--commit", required=True) + verify_record_parser.add_argument("--kind", required=True) + verify_record_parser.add_argument("--actor") + verify_record_parser.add_argument("--json", action="store_true") + dogfood_parser = sub.add_parser("dogfood-eval") dogfood_parser.add_argument("--output", type=Path, default=DEFAULT_DOGFOOD_RESULTS) dogfood_parser.add_argument("--work-dir", type=Path) @@ -519,7 +537,14 @@ def main(argv: list[str] | None = None) -> int: print(json.dumps(payload, sort_keys=True) if args.json else json.dumps(payload, indent=2)) return 0 if args.command == "reverify": - payload = commands.reverify_worklist(args.repo, args.changed_entity_key_id, args.depth) + attest_bundle = None + if args.attest_bundle is not None: + # PUSHED, UNTRUSTED payload — read + JSON-parse only; the consumer does + # the mechanical validation (schema/dirty/commit/content_hash). + attest_bundle = json.loads(Path(args.attest_bundle).read_text(encoding="utf-8")) + payload = commands.reverify_worklist( + args.repo, args.changed_entity_key_id, args.depth, attest_bundle=attest_bundle + ) print(json.dumps(payload, sort_keys=True) if args.json else json.dumps(payload, indent=2)) return 0 if args.command == "capture-snapshot": @@ -530,6 +555,19 @@ def main(argv: list[str] | None = None) -> int: ) print(json.dumps(payload, sort_keys=True) if args.json else json.dumps(payload, indent=2)) return 0 + if args.command == "verify-record": + payload = commands.verify_record( + args.repo, + commit=args.commit, + kind=args.kind, + actor=args.actor, + ) + print( + json.dumps(payload, sort_keys=True) + if args.json + else json.dumps(payload, indent=2) + ) + return 0 if args.command == "dogfood-eval": payload = run_dogfood_evaluator( output_path=args.output, diff --git a/src/warpline/commands.py b/src/warpline/commands.py index e3e44c2..53d4933 100644 --- a/src/warpline/commands.py +++ b/src/warpline/commands.py @@ -5,7 +5,9 @@ from pathlib import Path from typing import Any +from warpline._attest import worklist_risk from warpline._blast import enrich_blast, resolve_changed_inputs, rev_range_commits +from warpline._completeness import compute_impact_completeness from warpline._enrichment import ( EDGES_FOR_COMPLETENESS, completeness_warnings, @@ -14,8 +16,14 @@ staleness_warnings, ) from warpline.envelope import build_envelope, enrichment_state -from warpline.errors import BadRevisionError, InvalidChangedRefsError +from warpline.errors import ( + BadRevisionError, + InternalError, + InvalidChangedRefsError, + MissingRequiredFieldError, +) from warpline.federation import LegisClient, RiskClient, consult_federation +from warpline.git import commits_between, is_ancestor, resolve_commit from warpline.listing import ( apply_filters, apply_group_by, @@ -28,6 +36,7 @@ LoomweaveMcpClient, LoomweaveProbe, loomweave_resolve_qualnames, + resolve_content_hash_for_locator, ) from warpline.propagation import blast_radius as compute_blast_radius from warpline.refs import ( @@ -39,7 +48,13 @@ from warpline.reverify import render_reverify_worklist from warpline.siblings import RenameFeed, WorkClient from warpline.snapshot import capture_edge_snapshot -from warpline.store import WarplineStore, default_store_path +from warpline.store import ( + STORE_STATUS_VOCAB, + WarplineStore, + default_store_path, + read_store_binding, +) +from warpline.verification import compose_verification_freshness # FROZEN schema URIs (one contract per tool; endorsed name and shim share it). SCHEMA_CHANGE_LIST = "warpline.change_list.v1" @@ -48,6 +63,8 @@ SCHEMA_IMPACT_RADIUS = "warpline.impact_radius.v1" SCHEMA_REVERIFY_WORKLIST = "warpline.reverify_worklist.v1" SCHEMA_EDGE_SNAPSHOT = "warpline.edge_snapshot.v1" +SCHEMA_VERIFICATION_RECORD = "warpline.verification_record.v1" +SCHEMA_PROJECT_STATUS = "warpline.project_status.v1" def session_context(repo: Path) -> str: @@ -231,18 +248,26 @@ def change_list( with WarplineStore.open(default_store_path(repo)) as store: events = store.list_change_events(repo, commit_shas=commit_shas) items: list[dict[str, Any]] = [] - changed_refs: list[dict[str, str]] = [] - seen_refs: set[tuple[str, str]] = set() - key_ids: list[int] = [] + # change_id -> (changed_ref, entity_key_id): the per-event follow-up SEED + # inputs, rebuilt from the FILTERED set below so the reverify/blast + # next_actions and data.changed_refs narrow with a filter too — not just + # the visible items. Keyed by the unique change_id so the rebuild is + # order-preserving and byte-identical to the per-event source when no + # filter is active (event_key_id is read from the event, never the view). + seed_by_change_id: dict[str, tuple[dict[str, str], Any]] = {} has_sei = False for event in events: path = str(event.get("path")) view = entity_view(event, include_key_id=True, path=path) if view["sei"]: + # whether the underlying (unfiltered) change-set resolved any SEI: + # an enrichment posture, not a next_action seed, so it stays on the + # FULL set (unlike changed_refs/key_ids, which DO narrow per filter). has_sei = True + change_id = f"warpline:change:{event.get('change_event_id')}" items.append( { - "change_id": f"warpline:change:{event.get('change_event_id')}", + "change_id": change_id, "entity": view, "change_kind": event.get("change_kind"), "actor": event.get("actor"), @@ -250,18 +275,28 @@ def change_list( "changed_at": event.get("changed_at"), } ) - ref = changed_ref_for_row(event) + seed_by_change_id[change_id] = ( + changed_ref_for_row(event), + event.get("entity_key_id"), + ) + + # Filter → sort → overflow-bound → page: the list-ergonomics pipeline, + # each step honoring its advertised knob or rejecting it loudly. + items = apply_filters(items, tool="warpline_change_list", filters=filters) + # Seeds from the FILTERED (pre-page) set: a path_prefix/actor/since filter + # must narrow the advertised reverify/blast scope too, or following the + # next_action rechecks unfiltered changes. Deduped, order-preserving. + changed_refs: list[dict[str, str]] = [] + key_ids: list[int] = [] + seen_refs: set[tuple[str, str]] = set() + for item in items: + ref, key_id = seed_by_change_id[item["change_id"]] ref_key = (ref["kind"], ref["value"]) if ref_key not in seen_refs: seen_refs.add(ref_key) changed_refs.append(ref) - key_id = event.get("entity_key_id") if isinstance(key_id, int) and key_id not in key_ids: key_ids.append(key_id) - - # Filter → sort → overflow-bound → page: the list-ergonomics pipeline, - # each step honoring its advertised knob or rejecting it loudly. - items = apply_filters(items, tool="warpline_change_list", filters=filters) items = apply_sort( items, tool="warpline_change_list", sort_by=sort_by, sort_order=sort_order ) @@ -308,7 +343,6 @@ def change_list( } sei_state = "present" if has_sei else "absent" sei_triple = sei_reason(sei_state) - assert sei_triple is not None # present/absent are always in-vocab return build_envelope( SCHEMA_CHANGE_LIST, query=query, @@ -397,7 +431,6 @@ def entity_timeline( } sei_state = "present" if entity_out["sei"] else "absent" sei_triple = sei_reason(sei_state) - assert sei_triple is not None # present/absent are always in-vocab if rename_feed is not None: governance_reason = reason("clean") else: @@ -501,7 +534,6 @@ def entity_churn_count( } sei_state = "present" if has_sei else "absent" sei_triple = sei_reason(sei_state) - assert sei_triple is not None # present/absent are always in-vocab return build_envelope( SCHEMA_ENTITY_CHURN_COUNT, query=query, @@ -661,6 +693,40 @@ def _lazy_capture_if_missing( return +def _attest_content_hashes( + repo: Path, + affected_seis: list[str], + sei_to_locator: dict[str, str], + loomweave_command: str | None, +) -> dict[str, str]: + """Build ``{sei: current content_hash}`` for the worklist's affected SEIs via + loomweave, for the risk-as-verification consult. ``sei_to_locator`` is built by + the caller from the FULL (pre-page) worklist set, so a > limit worklist still + resolves every affected entity. Fail-soft and read-only: an unreachable + loomweave (or an unresolvable entity) yields no hash for that SEI, so the attest + consumer honestly leaves it unmatched (``attestation_incomplete``) — NEVER a + faked-good match. One ``entity_resolve`` round trip per SEI (batching is a clean + later optimization); only paid when an attest bundle was supplied.""" + + by_sei: dict[str, str] = {} + try: + command = loomweave_command or os.environ.get("WARPLINE_LOOMWEAVE_COMMAND", "loomweave") + client = LoomweaveMcpClient(repo=repo, command=command) + try: + for sei in affected_seis: + locator = sei_to_locator.get(sei) + if locator is None: + continue + content_hash = resolve_content_hash_for_locator(client, locator) + if content_hash: + by_sei[sei] = content_hash + finally: + _close_if_supported(client) + except Exception: # noqa: BLE001 — advisory consult; never block the worklist. + return by_sei + return by_sei + + # --------------------------------------------------------------------------- # warpline_impact_radius_get — warpline.impact_radius.v1 # --------------------------------------------------------------------------- @@ -760,6 +826,7 @@ def reverify_worklist( risk_client: RiskClient | None = None, legis_client: LegisClient | None = None, loomweave_command: str | None = None, + attest_bundle: Any = None, ) -> dict[str, Any]: refs = parse_changed_refs(changed_refs) with WarplineStore.open(default_store_path(repo)) as store: @@ -777,17 +844,123 @@ def reverify_worklist( changed, affected = enrich_blast(store, repo, result) completeness = result["completeness"] staleness = result["staleness"] + + # Rung 2 Track B — verification freshness (advisory, never gates). + # Align entity_key_id to changed/affected ORDER. enrich_blast preserves + # the order of result["changed"]/result["affected"] (verified _blast.py:142-157), + # whose rows carry entity_key_id; the FROZEN {locator, sei} entity view never + # does. The positional alignment changed[i] <-> changed_key_ids[i] is the + # invariant render_reverify_worklist relies on to attach the block. + changed_key_ids: list[int | None] = [ + r.get("entity_key_id") if isinstance(r.get("entity_key_id"), int) else None + for r in result.get("changed", []) + ] + affected_key_ids: list[int | None] = [ + r.get("entity_key_id") if isinstance(r.get("entity_key_id"), int) else None + for r in result.get("affected", []) + ] + # Load ONLY the worklist's change commits (no full-table scan — push the + # entity filter into SQL) and group by key id; load verification events once. + worklist_key_ids = [k for k in (*changed_key_ids, *affected_key_ids) if k is not None] + verification_events = store.list_verification_events(repo) + local_source_configured = len(verification_events) > 0 + changes_by_key: dict[int, list[str]] = {} + for ce in store.list_change_events_for_key_ids(repo, worklist_key_ids): + kid = ce.get("entity_key_id") + if isinstance(kid, int): + sha = str(ce.get("commit_sha")) + bucket = changes_by_key.setdefault(kid, []) + # One entity can have several change_event rows for the SAME commit + # (the UNIQUE key is (repo, entity_key_id, commit_sha, path, change_kind), + # not commit_sha alone). Collapse adjacent duplicates (rows are + # oldest-first) so the covers() fan-out isn't wasted and + # entity_change_commits[-1] stays the true latest distinct commit. + if not bucket or bucket[-1] != sha: + bucket.append(sha) + + def _covers(verified_commit: str, change_commit: str) -> bool | None: + # NOTE the argument inversion: a change is COVERED by a verification + # iff the change commit is an ancestor-or-equal of the verified commit + # (the gate ran at/after the change). So covers(verified, change) maps + # to is_ancestor(ancestor=change_commit, descendant=verified_commit). + return is_ancestor(repo, change_commit, verified_commit) + + def _between(ancestor: str, descendant: str) -> int | None: + return commits_between(repo, ancestor, descendant) + + _verif_cache: dict[int, dict[str, Any]] = {} + + def verification_for(kid: int | None) -> dict[str, Any]: + # kid is None for an affected row that carried no entity_key_id; + # compose([], ...) honestly yields "unverified" (nothing to verify). + if kid is None: + return compose_verification_freshness([], verification_events, _covers, _between) + if kid not in _verif_cache: + _verif_cache[kid] = compose_verification_freshness( + changes_by_key.get(kid, []), + verification_events, + _covers, + _between, + ) + return _verif_cache[kid] + items, work_seen, filigree_candidates = render_reverify_worklist( changed=changed, affected=affected, completeness=completeness, staleness=staleness, work_client=work_client, + changed_key_ids=changed_key_ids, + affected_key_ids=affected_key_ids, + verification_for=verification_for, ) items = apply_filters(items, tool="warpline_reverify_worklist_get", filters=filters) + # Advisory: stale-of-trust first. Stable presort run JUST BEFORE apply_sort + # so apply_sort stays the PRIMARY key (last stable sort wins) and stale-first + # is the secondary tiebreak within ties. Never reorders across the primary + # key; never removes an item. Relies on apply_sort being a stable sorted() + # (listing.py:332) with a sort_by=None passthrough (listing.py:306). + # The sort key is (depth, state_rank) so depth stays the default primary + # ordering when apply_sort is a passthrough (sort_by=None) AND stale-first + # serves as the secondary tiebreak within same-depth groups. + _state_rank = {"stale": 0, "unavailable": 1, "unverified": 2, "fresh": 3} + items.sort( + key=lambda it: ( + it.get("depth", 0), + _state_rank.get(it["verification"]["state"], 3), + ) + ) items = apply_sort( items, tool="warpline_reverify_worklist_get", sort_by=sort_by, sort_order=sort_order ) + # verification_summary reflects the post-filter, pre-page set (mirrors how + # completeness/staleness describe the requested set, not the current page). + verification_summary = { + "fresh": sum(1 for it in items if it["verification"]["state"] == "fresh"), + "stale": sum(1 for it in items if it["verification"]["state"] == "stale"), + "unverified": sum(1 for it in items if it["verification"]["state"] == "unverified"), + "unavailable": sum(1 for it in items if it["verification"]["state"] == "unavailable"), + "local_source_configured": local_source_configured, + } + # Risk-as-verification (Rung 2): the SEIs of the FULL filtered+sorted set + # (pre-page, like verification_summary) — the verdict describes the whole + # change's worklist, never a single page. Deduped, order-preserving. The + # sei->locator map is captured HERE, from the full set, NOT from the + # post-apply_page `items` below — otherwise an affected entity that fell off + # page 1 would have no locator, no content_hash, and a > limit worklist + # could never read proven-good even when the bundle attests every entity. + affected_seis: list[str] = [] + affected_sei_locators: dict[str, str] = {} + _seen_seis: set[str] = set() + for it in items: + entity = it.get("entity", {}) + sei = entity.get("sei") + if isinstance(sei, str) and sei and sei not in _seen_seis: + _seen_seis.add(sei) + affected_seis.append(sei) + loc = entity.get("locator") + if isinstance(loc, str) and loc: + affected_sei_locators[sei] = loc # group_by buckets the FULL filtered+sorted list (the grouped view is a # complete projection, not a page); the flat list still paginates. grouped = apply_group_by(items, tool="warpline_reverify_worklist_get", group_by=group_by) @@ -821,9 +994,48 @@ def reverify_worklist( items, repo=repo, tool="warpline_reverify_worklist_get", schema=SCHEMA_REVERIFY_WORKLIST ) items, page = apply_page(items, limit=limit, cursor=cursor) + # Federation D1: the self-assessed completeness+staleness of THIS impact + # analysis (additive v1 OBJECT, distinct from the FROZEN raw-snapshot + # `completeness` STRING above). wardline mirrors this single object verbatim + # into its own `producer_completeness` scope-honesty field. Both axes live + # inside it: the staleness axis (`as_of` producer timestamp + graph_fresh + + # graph_ref) and the completeness axis (status + depth_capped + + # unresolved_count). warpline's raw `staleness`/`completeness` are untouched. + impact_completeness = compute_impact_completeness( + as_of=_now().isoformat(), + completeness=completeness, + staleness=staleness, + unresolved=unresolved, + depth_capped=bool(result.get("depth_capped", False)), + ) + # Risk-as-verification (Rung 2): warpline's honest verification posture for + # this change. Always emitted. A complete worklist whose every affected + # entity is attested clean at its CURRENT body by the PUSHED, UNTRUSTED + # wardline-attest-2 `attest_bundle` reads proven-good (echo of wardline's + # authority, HMAC unverified); absent/partial/unmatched degrades to + # `unavailable` with an explicit machine reason — never a warpline clean. + # The per-SEI current content_hash is sourced from loomweave (fail-soft; + # only paid when a bundle is supplied — an unreachable loomweave yields no + # hashes, so every entity is honestly unmatched, never faked-good). + content_hash_by_sei = ( + _attest_content_hashes(repo, affected_seis, affected_sei_locators, loomweave_command) + if attest_bundle is not None + else {} + ) + risk_verification = worklist_risk( + impact_completeness, + affected_seis=affected_seis, + bundle=attest_bundle, + # only paid when a bundle is present (worklist_risk ignores it otherwise) + current_commit=resolve_commit(repo, "HEAD") if attest_bundle is not None else None, + content_hash_for_sei=content_hash_by_sei.get, + ) data = { "completeness": completeness, + "impact_completeness": impact_completeness, + "risk_verification": risk_verification, "staleness": staleness, + "verification_summary": verification_summary, "resolved": resolved, "unresolved": unresolved, "items": items, @@ -1028,7 +1240,6 @@ def capture_snapshot( client_available = status == "available" with WarplineStore.open(default_store_path(repo)) as store: existing = store.latest_snapshot(repo) - had_snapshot = existing is not None warnings: list[str] = [] data: dict[str, Any] scope_locators: set[str] | None = None @@ -1097,7 +1308,14 @@ def capture_snapshot( ) finally: _close_if_supported(client) - result["idempotency"] = "already_current" if had_snapshot else "created" + # Idempotency reflects whether THIS capture was skipped/reused, not + # whether ANY prior snapshot existed: a capture that writes a new row + # for a different commit is still `created`. The only honest + # `already_current` is the loomweave-absent preserve path, which flags + # `recapture_skipped` because it wrote nothing. + result["idempotency"] = ( + "already_current" if result.get("recapture_skipped") else "created" + ) result.pop("query", None) result.pop("enrichment", None) if result.get("capped"): @@ -1105,6 +1323,11 @@ def capture_snapshot( f"CAPPED: max_entities={cap} limited the captured entity set; completeness " "downgraded to DELTA (affected-set is not complete)" ) + if result.get("recapture_skipped"): + warnings.append( + f"PRESERVED: loomweave unavailable; existing {result.get('completeness')} " + f"snapshot @ {result.get('commit_sha')} retained, not refreshed" + ) data = { "snapshot_id": result.get("snapshot_id"), "commit_sha": result.get("commit_sha"), @@ -1138,7 +1361,6 @@ def capture_snapshot( "page": {"limit": None, "cursor": None}, } capture_sei_triple = sei_reason(sei_state) - assert capture_sei_triple is not None # unavailable/absent are always in-vocab return build_envelope( SCHEMA_EDGE_SNAPSHOT, query=query, @@ -1147,3 +1369,142 @@ def capture_snapshot( enrichment_reasons={"sei": capture_sei_triple}, warnings=completeness_warnings(str(data["completeness"])) + warnings, ) + + +def verify_record( + repo: Path, + *, + commit: str, + kind: str, + actor: str | None = None, + now: str | None = None, +) -> dict[str, Any]: + """Record a verification (gate-pass) event for ``commit``. + + The 2nd mutating verb (besides capture-snapshot). Writes ONE row to the + local ``verification_events`` table (``.weft/warpline/`` only); never a + sibling repo. ``commit`` is resolved to an object SHA before storage — a + symbolic ref is never persisted. ``kind`` is a free-form non-empty provenance + label (e.g. ``test_pass`` / ``ci_pass`` / ``gate_pass``). Idempotent on + (repo, commit, kind, source=warpline). + """ + + kind_clean = kind.strip() + if not kind_clean: + raise MissingRequiredFieldError( + "kind must be a non-empty verification label, e.g. test_pass", + rejected_field="kind", + ) + if not commit or not commit.strip(): + raise MissingRequiredFieldError( + "commit must be a non-empty ref or SHA", + rejected_field="commit", + ) + resolved = resolve_commit(repo, commit) + if resolved is None: + raise BadRevisionError( + f"could not resolve commit ref {commit!r} to an object SHA", + rejected_field="commit", + ) + verified_at = now or _now().isoformat() + with WarplineStore.open(default_store_path(repo)) as store: + repo_id = store.ensure_repo(repo) + inserted = store.record_verification_event( + repo_id=repo_id, + commit_sha=resolved, + kind=kind_clean, + verified_at=verified_at, + actor=actor, + source="warpline", + ) + data = { + "commit_sha": resolved, + "kind": kind_clean, + "verified_at": verified_at, + "actor": actor, + "source": "warpline", + "idempotency": "recorded" if inserted else "already_recorded", + } + query = { + "repo": str(repo), + "tool": "warpline_verification_record", + "arguments": {"commit": commit, "kind": kind, "actor": actor}, + "filters": {}, + "sort": {}, + "page": {"limit": None, "cursor": None}, + } + return build_envelope( + SCHEMA_VERIFICATION_RECORD, + query=query, + data=data, + enrichment=enrichment_state(), + warnings=[], + ) + + +# --------------------------------------------------------------------------- +# warpline_project_status_get — warpline.project_status.v1 +def project_status(repo: Path) -> dict[str, Any]: + """Read-only store-binding/health probe — the federation-attachment signal. + + Reports whether THIS warpline build can read and SERVE the snapshot store for + ``repo`` (``data.binding_ok``). warpline is repo-per-call — bound to nothing + at launch — so this is a *can-service-R* check, never a launch-time binding: + given ``repo=R`` the server reads the schema version FROM INSIDE R's snapshot + store (``data.store.schema_version``, ``null`` when absent/unreadable), so a + stale binary that cannot read its store is caught — unlike mere directory + existence, which such a binary would still see. + + Strictly read-only: it creates and migrates no snapshot STATE (unlike every + other read tool, which lazily opens — and thus initializes — the store). An + absent store reports absent (no DB created) with a ``capture_snapshot`` + next-action hint, and a present store's ``warpline.db`` is left byte-for-byte + unchanged. (Opening a present WAL store read-only may spawn gitignored SQLite + ``-wal``/``-shm`` coordination sidecars — not snapshot state.) + """ + + resolved = repo.resolve() + binding = read_store_binding(repo) + # Fail closed if the store-read ever yields a status outside the closed vocab + # (the same discipline build_envelope applies to the enrichment vocabulary). + if binding.status not in STORE_STATUS_VOCAB: + raise InternalError( + f"store binding produced status {binding.status!r} outside STORE_STATUS_VOCAB" + ) + data: dict[str, Any] = { + "resolved_root": str(resolved), + "store": { + "present": binding.present, + "readable": binding.readable, + "schema_version": binding.schema_version, + "snapshot_rev": binding.snapshot_rev, + "change_event_count": binding.change_event_count, + }, + "store_status": binding.status, + "binding_ok": binding.binding_ok, + } + warnings = [] if binding.binding_ok else [binding.detail] + next_actions: dict[str, Any] = {} + if binding.status == "store_absent": + next_actions = { + "warpline_edge_snapshot_capture": { + "tool": "warpline_edge_snapshot_capture", + "arguments": {"repo": str(resolved)}, + } + } + query = { + "repo": str(resolved), + "tool": "warpline_project_status_get", + "arguments": {}, + "filters": {}, + "sort": {}, + "page": {"limit": None, "cursor": None}, + } + return build_envelope( + SCHEMA_PROJECT_STATUS, + query=query, + data=data, + enrichment=enrichment_state(), + next_actions=next_actions, + warnings=warnings, + ) diff --git a/src/warpline/dogfood.py b/src/warpline/dogfood.py index 538a701..d4d70d5 100644 --- a/src/warpline/dogfood.py +++ b/src/warpline/dogfood.py @@ -264,9 +264,11 @@ def _run_real_member_case(root: Path, source_repo: Path) -> dict[str, Any]: locator = item.get("entity", {}).get("locator") if isinstance(item, dict) else None if isinstance(locator, str) and resolve_sei_for_locator(sei_client, locator): sei_resolved += 1 + # Warpline tracks code entities (Python, Rust, etc.) not docs/Makefile/README; + # use subset check: warpline_paths ⊆ baseline_paths is correct parity. parity = ( baseline["baseline_executed"] is True - and baseline_paths == warpline_paths + and warpline_paths <= baseline_paths and bool(changed_key_ids) and reverify_data.get("completeness") in {"FULL", "DELTA"} ) diff --git a/src/warpline/envelope.py b/src/warpline/envelope.py index 46df3aa..858647c 100644 --- a/src/warpline/envelope.py +++ b/src/warpline/envelope.py @@ -86,6 +86,12 @@ def build_envelope( f"enrichment_reasons.{dim} must be a listing.reason() triple " f"(a dict carrying a canonical reason_class)" ) + if carrier["reason_class"] != "clean" and not (carrier.get("cause") and carrier.get("fix")): + raise ValueError( + f"enrichment_reasons.{dim} is a non-clean reason_class " + f"({carrier['reason_class']!r}) but is missing cause and/or fix " + f"(the unexplained-absence the honesty doctrine forbids)" + ) return { "schema": schema, "ok": True, diff --git a/src/warpline/federation.py b/src/warpline/federation.py index 9f8fc3b..a5dbe88 100644 --- a/src/warpline/federation.py +++ b/src/warpline/federation.py @@ -6,7 +6,11 @@ * filigree — issues touching the SEIs (entity-association reverse lookup); * wardline — trust/risk findings keyed on the entity qualname (``dossier``); - * legis — governance / closure posture for the entity. + * legis — VERIFIED GOVERNANCE CLEARANCES for the entity (governance_read.v1, + cleared-only). An empty read is "no verified clearance", which + conflates ungoverned, unknown-SEI, AND actively-blocked-awaiting- + sign-off — so warpline renders it ``governance=absent`` ("no verified + clearance"), NEVER "ungoverned", and never gates on it. THE HONESTY INVARIANT (PDR-0023), applied per-member. include_federation is the mini-L2 strategic-view: a confident-empty federation block (a member silently @@ -104,6 +108,100 @@ def findings_for_locator(self, locator: str) -> list[dict[str, Any]]: return [] +# --------------------------------------------------------------------------- +# legis read transport — `legis governance-read ` (governance_read.v1; JSON-only) +# --------------------------------------------------------------------------- +class LegisGovernanceUnavailable(Exception): + """legis could not produce a signature-verifiable governance read. + + Raised for a ``status: unavailable`` envelope (tampered/unverifiable trail) AND + for any transport failure (nonzero exit, missing binary, unparseable output). + ``_consult_legis`` maps it to ``unreachable`` — an honest "asked, could not + answer", never a confident-empty. + """ + + def __init__(self, sei: str, reasons: list[dict[str, Any]] | None = None) -> None: + self.sei = sei + self.reasons = reasons or [] + super().__init__(f"legis governance read unavailable for {sei}: {self.reasons}") + + +class LegisGovernanceClient: + """Real legis read client over the ``legis governance-read`` CLI verb. + + legis OWNS the ``governance_read.v1`` contract (mirrored at + ``contracts/governance_read.v1.schema.json``); this is warpline's READ-ONLY, + advisory consult of it and never mutates legis state. The read reports VERIFIED + CLEARANCES ONLY (operator override / cleared sign-off) — so an empty + ``records`` is "no verified clearance", which deliberately CONFLATES truly + ungoverned, unknown-SEI, AND actively-BLOCKED-awaiting-sign-off. warpline + therefore renders empty as ``governance=absent`` ("no verified clearance"), + NEVER "ungoverned". The clearance ``content_hash`` is ECHOED verbatim and never + re-derived against the current body (governance is an echo, not a verdict). + """ + + def __init__(self, repo: Path, command: str = "legis", timeout: float = 30.0) -> None: + self.repo = repo + self.command = command + self.timeout = timeout + + @classmethod + def available(cls, repo: Path, command: str = "legis") -> bool: + """Does the installed legis advertise the ``governance-read`` verb? + + Gates the live wiring: until legis ships the read surface, the verb is + absent and the honest posture is ``disabled`` (capability absent), NOT a + forced ``unreachable``. A cheap ``--help`` probe — negligible against the + per-SEI filigree/wardline subprocesses already on the federated path. + """ + + try: + proc = subprocess.run( + [command, "--help"], + cwd=repo, + text=True, + capture_output=True, + timeout=10.0, + ) + except (OSError, subprocess.SubprocessError): + return False + return "governance-read" in (proc.stdout or "") + (proc.stderr or "") + + def governance_for_sei(self, sei: str) -> list[dict[str, Any]]: + try: + # `legis governance-read ` — output is ALWAYS JSON (no `--json` + # flag; passing one is an argparse error -> nonzero exit). Matches + # legis's shipped CLI contract (legis src/legis/cli.py). + proc = subprocess.run( + [self.command, "governance-read", sei], + cwd=self.repo, + check=True, + text=True, + capture_output=True, + timeout=self.timeout, + ) + except (OSError, subprocess.SubprocessError) as exc: + # nonzero exit (tampered trail / unknown verb) or missing binary. + raise LegisGovernanceUnavailable(sei) from exc + try: + payload = json.loads(proc.stdout) + except (json.JSONDecodeError, ValueError) as exc: + raise LegisGovernanceUnavailable(sei) from exc + if not isinstance(payload, dict): + raise LegisGovernanceUnavailable(sei) + status = payload.get("status") + if status == "unavailable": + unavailable = payload.get("unavailable") + reasons = unavailable if isinstance(unavailable, list) else None + raise LegisGovernanceUnavailable(sei, reasons) + if status != "checked": + raise LegisGovernanceUnavailable(sei) + records = payload.get("records", []) + if not isinstance(records, list): + return [] + return [r for r in records if isinstance(r, dict)] + + # --------------------------------------------------------------------------- # per-member consult — each returns (entries_by_locator, member_reason) # --------------------------------------------------------------------------- @@ -188,20 +286,21 @@ def _consult_legis( items: list[dict[str, Any]], legis_client: LegisClient | None ) -> tuple[dict[str, list[dict[str, Any]]], dict[str, Any]]: if legis_client is None: - # The legis CLI exposes only serve/mcp/gate verbs — there is NO per-SEI / - # per-entity governance read on the CLI. Honest posture: disabled with a - # recruiting fix, NEVER a faked-empty governance result. Reported as a - # transport_blocker for the strike. + # A LegisGovernanceClient EXISTS now, but it is only wired when the installed + # legis advertises the `governance-read` verb (capability-gated in mcp.py). + # When it does not, the honest posture is `disabled` (the read CAPABILITY is + # absent) — NOT `unreachable` (which would imply a wired-but-down transport) + # and NEVER a faked-empty governance result. Reported as a transport_blocker. return {}, reason( "disabled", cause=( - "no per-entity legis governance read transport is wired: the legis CLI exposes " - "serve/mcp/governance-gate only, not a per-SEI closure/posture read" + "the legis governance-read surface (governance_read.v1) is not available from " + "the installed legis: no per-SEI verified-clearance read was advertised" ), fix=( - "wire a LegisClient over the legis governance read surface " - "(GET /api/.../governance keyed on the SEI, or the legis MCP governance read) " - "and pass it to reverify; until then governance is honestly disabled, not empty" + "install/upgrade legis to a version exposing the `governance-read` verb " + "(governance_read.v1); warpline auto-wires its LegisGovernanceClient once the " + "verb is advertised, so governance lights up — until then it is honestly disabled" ), ) by_locator: dict[str, list[dict[str, Any]]] = {} @@ -310,8 +409,9 @@ def federation_transport_blockers( { "member": "legis", "need": ( - "a per-entity legis governance read transport — the legis CLI exposes only " - "serve/mcp/governance-gate, no per-SEI closure/posture read" + "a legis exposing the `governance-read` verb (governance_read.v1) so the " + "per-SEI verified-governance read lights up — warpline's LegisGovernanceClient " + "auto-wires once the installed legis advertises it" ), } ) diff --git a/src/warpline/git.py b/src/warpline/git.py index a715235..4019b27 100644 --- a/src/warpline/git.py +++ b/src/warpline/git.py @@ -82,6 +82,65 @@ def _git_optional(repo: Path, args: list[str]) -> str | None: return result.stdout.strip() +def resolve_commit(repo: Path, ref: str) -> str | None: + """Resolve ``ref`` to a 40-hex commit object SHA, or None if unresolvable. + + Uses ``rev-parse --verify ^{commit}`` so a tag/branch/``HEAD`` resolves + to the underlying commit and a non-commit object is rejected. Never raises: + a bad ref returns None for the caller to turn into a structured error. + """ + + # The doubled braces escape to a literal ``^{commit}`` (git peel-to-commit + # syntax) — they are f-string brace escaping, not part of the ref. + out = _git_optional(repo, ["rev-parse", "--verify", "--quiet", f"{ref}^{{commit}}"]) + if out is None: + return None + return out if len(out) == 40 else None + + +def is_ancestor(repo: Path, ancestor: str, descendant: str) -> bool | None: + """Is ``ancestor`` an ancestor-or-equal of ``descendant``? + + Wraps ``git merge-base --is-ancestor``: exit 0 -> True, exit 1 -> False, any + other exit (unknown/missing commit, shallow clone) -> None ("could not + compute" — fail-soft, never a crash, never a silent False). + """ + + proc = subprocess.run( + ["git", "merge-base", "--is-ancestor", ancestor, descendant], + cwd=repo, + check=False, + capture_output=True, + ) + if proc.returncode == 0: + return True + if proc.returncode == 1: + return False + return None + + +def commits_between(repo: Path, ancestor: str, descendant: str) -> int | None: + """Count commits in ``ancestor..descendant`` (excludes ancestor), or None. + + ``git rev-list --count ancestor..descendant``. None on any git failure + (unknown commit, etc.). Zero when the two are the same commit. + """ + + proc = subprocess.run( + ["git", "rev-list", "--count", f"{ancestor}..{descendant}"], + cwd=repo, + check=False, + text=True, + capture_output=True, + ) + if proc.returncode != 0: + return None + try: + return int(proc.stdout.strip()) + except ValueError: + return None + + def _detect_anchor(repo: Path) -> _Anchor: """Compute the working-context anchor once, at detection time. diff --git a/src/warpline/listing.py b/src/warpline/listing.py index fe8ab38..4b6dac2 100644 --- a/src/warpline/listing.py +++ b/src/warpline/listing.py @@ -37,10 +37,12 @@ def reason( """Build a weft-reason carrier. ``clean`` omits cause/fix; every other class MUST carry both (fix recruits the caller toward what they wanted).""" - assert reason_class in REASON_CLASSES, f"{reason_class!r} not in the canonical 11" + if reason_class not in REASON_CLASSES: + raise ValueError(f"{reason_class!r} not in the canonical 11") if reason_class == "clean": return {"reason_class": "clean"} - assert cause and fix, f"non-clean reason {reason_class!r} requires both cause and fix" + if not (cause and fix): + raise ValueError(f"non-clean reason {reason_class!r} requires both cause and fix") return {"reason_class": reason_class, "cause": cause, "fix": fix} @@ -372,6 +374,18 @@ def apply_page( a normal page, ``partial`` when a cursor lands at or past the end (an honest empty page, never a silent clean-empty).""" + # A non-positive limit would slice an empty window WITHOUT advancing the + # offset, so a non-empty result reports has_more:true with a next_cursor equal + # to the current offset — a cursor-following client loops forever. Reject it + # loudly, exactly as max_entities / the cursor offset reject non-positive + # bounds (PDR-0023: honor the advertised knob or reject it, never quietly + # mis-page). InvalidSortError is the pagination family's error (its sibling + # cursor knob raises the same); rejected_field names the offending input. + if not isinstance(limit, int) or isinstance(limit, bool) or limit <= 0: + raise InvalidSortError( + "limit must be a positive integer", rejected_field="limit" + ) + offset = decode_cursor(cursor) total = len(items) window = items[offset : offset + limit] diff --git a/src/warpline/loomweave.py b/src/warpline/loomweave.py index 23efcb2..43a167b 100644 --- a/src/warpline/loomweave.py +++ b/src/warpline/loomweave.py @@ -299,6 +299,59 @@ def _sei_from_resolve_results(payload: dict[str, object], locator: str) -> str | return None +def resolve_content_hash_for_locator(client: ToolClient, locator: str) -> str | None: + """The entity-body ``content_hash`` loomweave records for ``locator`` (or None). + + Sourced from the SAME ``entity_resolve`` round trip warpline already uses for the + SEI (the result/candidate carries both ``sei`` and ``content_hash``), so the body + hash warpline compares against a ``wardline-attest-2`` boundary is loomweave's own + per-entity hash — the identical value wardline binds into the bundle (confirmed + byte-equal across loomweave's MCP ``entity_resolve`` and its HTTP + ``/api/v1/identity/sei`` surface, which is wardline's bundle-builder source).""" + + candidates = loomweave_resolve_qualnames(locator) + try: + payload = client.call_tool("entity_resolve", {"qualnames": candidates}) + except Exception: + return None + for candidate in candidates: + chash = _content_hash_from_resolve_results(payload, candidate) + if chash is not None: + return chash + entity = payload.get("entity") if isinstance(payload, dict) else None + if isinstance(entity, dict): + chash = entity.get("content_hash") + return chash if isinstance(chash, str) and chash else None + return None + + +def _content_hash_from_resolve_results(payload: dict[str, object], locator: str) -> str | None: + results = payload.get("results") + if not isinstance(results, list): + return None + for result in results: + if not isinstance(result, dict): + continue + qualname = result.get("qualname") + if isinstance(qualname, str) and qualname != locator: + continue + entity = result.get("entity") + if isinstance(entity, dict): + chash = entity.get("content_hash") + if isinstance(chash, str) and chash: + return chash + candidates = result.get("candidates") + if not isinstance(candidates, list): + continue + for candidate in candidates: + if not isinstance(candidate, dict): + continue + chash = candidate.get("content_hash") + if isinstance(chash, str) and chash: + return chash + return None + + _SOURCE_ROOTS = ("src", "lib") diff --git a/src/warpline/mcp.py b/src/warpline/mcp.py index 24bbea7..c70fab5 100644 --- a/src/warpline/mcp.py +++ b/src/warpline/mcp.py @@ -14,7 +14,7 @@ MissingRequiredFieldError, WarplineError, ) -from warpline.federation import WardlineDossierClient +from warpline.federation import LegisGovernanceClient, WardlineDossierClient from warpline.siblings import FiligreeWorkClient CORE_OUTPUT_SCHEMA = { @@ -210,6 +210,12 @@ def _tool_spec( # with no transport is honestly ``disabled``, never silently dropped — # so this is a kept promise, not a re-advertised-dead field. "include_federation": {"type": "boolean"}, + # attest_bundle: a PUSHED, UNTRUSTED wardline-attest-2 bundle (the JSON + # object). When supplied, data.risk_verification reads proven-good iff a + # complete worklist's every affected entity is attested clean at its + # current body (mechanical (commit, content_hash) equality; the HMAC is + # NOT verified by warpline). Absent/partial/unmatched stays unavailable. + "attest_bundle": {"type": "object"}, }, required=["repo"], metadata=_READ_META_LW, @@ -240,6 +246,57 @@ def _tool_spec( federation_dependencies=["loomweave"], ), ), + _tool_spec( + endorsed="warpline_verification_record", + shim="verify_record", + schema=commands.SCHEMA_VERIFICATION_RECORD, + description=( + "Record a verification (gate-pass) for a commit, e.g. test_pass. Mutates ONLY " + ".weft/warpline state; never a sibling repo. Advisory; warpline never gates." + ), + input_properties={ + "commit": {"type": "string"}, + "kind": {"type": "string"}, + "actor": {"type": ["string", "null"]}, + }, + required=["repo", "commit", "kind"], + metadata=_metadata( + read_only=False, + writes_local_state=True, + idempotent=True, + mutates_paths=[".weft/warpline/"], + federation_dependencies=[], + ), + ), + _tool_spec( + endorsed="warpline_project_status_get", + shim="project_status", + schema=commands.SCHEMA_PROJECT_STATUS, + description=( + "Read-only store-binding/health probe: reports whether THIS warpline build can " + "read and SERVE the snapshot store for the given repo (binding_ok), reading " + "schema_version from INSIDE the store. warpline is repo-per-call, bound to nothing " + "at launch — this is a can-service-R check, never a launch-time binding. Creates and " + "migrates NOTHING; an absent store is reported absent." + ), + input_properties={}, + required=["repo"], + # GENUINELY read-only: unlike the other read tools (which lazily open — + # and thus initialize/migrate — the store, hence writes_local_state), this + # probe writes NO durable snapshot state: no DB is created for an absent + # store, no rows are written, and warpline.db is left byte-for-byte + # unchanged. mutates_paths=[] names that durable-state surface (opening a + # present WAL store read-only may still spawn gitignored SQLite -wal/-shm + # coordination sidecars, which are not snapshot state). It is the first + # tool with writes_local_state=False / mutates_paths=[]. + metadata=_metadata( + read_only=True, + writes_local_state=False, + idempotent=True, + mutates_paths=[], + federation_dependencies=[], + ), + ), ] @@ -396,11 +453,19 @@ def _h_reverify(args: dict[str, Any]) -> dict[str, Any]: include_federation = bool(args.get("include_federation", False)) # When the caller asks for federation, build the REAL read-only clients for # the members that have a transport. filigree (entity-association reverse - # lookup) and wardline (`dossier` findings) are wired; legis has no per-entity - # CLI read transport yet, so legis_client stays None and the federation block - # surfaces it as ``disabled`` with a recruiting fix (never faked). + # lookup) and wardline (`dossier` findings) are always wired. legis is + # CAPABILITY-GATED: its LegisGovernanceClient is wired only when the installed + # legis advertises the `governance-read` verb (governance_read.v1). When the + # read surface is absent the verb genuinely does not exist, so the honest + # posture is ``disabled`` (capability absent) rather than a forced + # ``unreachable`` — and the client lights up automatically once legis ships it. work_client = FiligreeWorkClient(repo) if include_federation else None risk_client = WardlineDossierClient(repo) if include_federation else None + legis_client = ( + LegisGovernanceClient(repo) + if include_federation and LegisGovernanceClient.available(repo) + else None + ) return commands.reverify_worklist( repo, _key_ids_arg(args), @@ -416,7 +481,8 @@ def _h_reverify(args: dict[str, Any]) -> dict[str, Any]: work_client=work_client, include_federation=include_federation, risk_client=risk_client, - legis_client=None, + legis_client=legis_client, + attest_bundle=args.get("attest_bundle"), ) @@ -433,10 +499,32 @@ def _h_capture(args: dict[str, Any]) -> dict[str, Any]: ) +def _h_verify_record(args: dict[str, Any]) -> dict[str, Any]: + return commands.verify_record( + _repo_arg(args), + commit=str(args.get("commit", "")), + kind=str(args.get("kind", "")), + actor=_opt_str(args, "actor"), + ) + + +def _h_project_status(args: dict[str, Any]) -> dict[str, Any]: + return commands.project_status(_repo_arg(args)) + + _HANDLERS: dict[str, Callable[[dict[str, Any]], dict[str, Any]]] = {} for _spec, _handler in zip( TOOL_SPECS, - [_h_change_list, _h_timeline, _h_churn, _h_impact, _h_reverify, _h_capture], + [ + _h_change_list, + _h_timeline, + _h_churn, + _h_impact, + _h_reverify, + _h_capture, + _h_verify_record, + _h_project_status, + ], strict=True, ): _HANDLERS[_spec["endorsed"]] = _handler @@ -503,6 +591,7 @@ def _h_capture(args: dict[str, Any]) -> dict[str, Any]: "cursor", "limit", "include_federation", + "attest_bundle", } ), # capture honors or loudly rejects EVERY advertised field: no fast-follow @@ -519,6 +608,9 @@ def _h_capture(args: dict[str, Any]) -> dict[str, Any]: "idempotency_key", } ), + "warpline_verification_record": frozenset({"repo", "commit", "kind", "actor"}), + # The binding probe consumes only repo (the standard _repo_arg contract). + "warpline_project_status_get": frozenset({"repo"}), } # The fast-follow placeholder set is EMPTY for every tool: there is no @@ -535,6 +627,8 @@ def _h_capture(args: dict[str, Any]) -> dict[str, Any]: "warpline_impact_radius_get": frozenset(), "warpline_reverify_worklist_get": frozenset(), "warpline_edge_snapshot_capture": frozenset(), + "warpline_verification_record": frozenset(), + "warpline_project_status_get": frozenset(), } diff --git a/src/warpline/mcp_smoke.py b/src/warpline/mcp_smoke.py index 3437122..e5a75de 100644 --- a/src/warpline/mcp_smoke.py +++ b/src/warpline/mcp_smoke.py @@ -43,6 +43,17 @@ def run_mcp_smoke(repo: Path, *, include_bad_input: bool = True) -> dict[str, An {"jsonrpc": "2.0", "id": 5, "method": "tools/list", "params": {}}, ] ) + # The read-only binding probe always answers (binding_ok may be false on a + # storeless smoke repo — that is a successful CALL with a false VERDICT, not a + # tool error), so the smoke asserts only its structural shape. + requests.append( + { + "jsonrpc": "2.0", + "id": 6, + "method": "tools/call", + "params": {"name": "project_status", "arguments": {"repo": str(repo)}}, + } + ) responses = _run_stdio_conversation(requests) checks = _checks(responses, include_bad_input=include_bad_input) return { @@ -117,9 +128,49 @@ def _checks( }, ] ) + status_response = by_id.get(6, {}) + checks.append( + { + "name": "project_status_reports_binding", + "ok": _project_status_ok(status_response), + "details": _project_status_summary(status_response), + } + ) return checks +def _project_status_ok(response: dict[str, Any]) -> bool: + payload = _structured_content(response) + if not isinstance(payload, dict) or payload.get("ok") is not True: + return False + if payload.get("schema") != "warpline.project_status.v1": + return False + data = payload.get("data") + if not isinstance(data, dict) or not isinstance(data.get("binding_ok"), bool): + return False + store = data.get("store") + # The load-bearing store-read field must be present (value may be null on a + # storeless repo — that is the honest absent sentinel). + return isinstance(store, dict) and "schema_version" in store + + +def _project_status_summary(response: dict[str, Any]) -> dict[str, Any]: + payload = _structured_content(response) + if not isinstance(payload, dict): + return {"payload": None} + data = payload.get("data") + if not isinstance(data, dict): + return {"schema": payload.get("schema"), "ok": payload.get("ok")} + store = data.get("store") + schema_version = store.get("schema_version") if isinstance(store, dict) else None + return { + "schema": payload.get("schema"), + "binding_ok": data.get("binding_ok"), + "store_status": data.get("store_status"), + "schema_version": schema_version, + } + + def _initialize_ok(result: object) -> bool: return ( isinstance(result, dict) diff --git a/src/warpline/propagation.py b/src/warpline/propagation.py index aff1dec..c80022a 100644 --- a/src/warpline/propagation.py +++ b/src/warpline/propagation.py @@ -48,6 +48,7 @@ def blast_radius( "affected": [], "staleness": {"snapshot_commit": None, "commits_behind": None}, "completeness": "NO_SNAPSHOT", + "depth_capped": False, } adjacency: dict[int, list[dict[str, Any]]] = {} @@ -57,12 +58,23 @@ def blast_radius( seen = set(changed_entity_key_ids) affected: list[dict[str, Any]] = [] + # depth_capped: did the bounded traversal leave reachable impact unexplored? A + # node at the depth horizon (current_depth >= depth) that still has an out-edge + # to an as-yet-unseen target means the affected set is truncated, NOT exhaustive. + # This is the honest signal the impact-completeness assessment needs — without + # it a narrowed depth-bounded scope reads as a complete one. + depth_capped = False queue: deque[tuple[int, int, list[dict[str, Any]]]] = deque( (key_id, 0, []) for key_id in changed_entity_key_ids ) while queue: current, current_depth, path = queue.popleft() if current_depth >= depth: + if not depth_capped: + for edge in adjacency.get(current, []): + if _as_int(edge["target_entity_key_id"]) not in seen: + depth_capped = True + break continue for edge in adjacency.get(current, []): target = _as_int(edge["target_entity_key_id"]) @@ -89,4 +101,5 @@ def blast_radius( "commits_behind": _commits_behind(repo, str(snapshot["commit_sha"])), }, "completeness": snapshot["completeness"], + "depth_capped": depth_capped, } diff --git a/src/warpline/reverify.py b/src/warpline/reverify.py index 177af2c..e328cd7 100644 --- a/src/warpline/reverify.py +++ b/src/warpline/reverify.py @@ -1,7 +1,9 @@ from __future__ import annotations +from collections.abc import Callable from typing import Any +from warpline.listing import reason from warpline.siblings import WorkClient, priority_from_work, work_enrichment_for_sei _SUGGESTED_VERIFICATION = [ @@ -16,6 +18,25 @@ def _empty_enrichment() -> dict[str, list[Any]]: return {"work": [], "risk": [], "governance": [], "requirements": []} +def _default_verification() -> dict[str, Any]: + """Honest default when no verification source is wired (advisory).""" + + return { + "state": "unverified", + "last_verified_at": None, + "last_verified_commit": None, + "decay": {"commits_behind": None}, + "reason": reason( + "disabled", + cause="no local verification source is configured for this worklist", + fix=( + "record a gate pass with `warpline verify-record --commit " + "--kind test_pass`" + ), + ), + } + + def render_reverify_worklist( *, changed: list[dict[str, Any]], @@ -23,31 +44,44 @@ def render_reverify_worklist( completeness: str, staleness: dict[str, Any], work_client: WorkClient | None = None, + changed_key_ids: list[int | None] | None = None, + affected_key_ids: list[int | None] | None = None, + verification_for: Callable[[int | None], dict[str, Any]] | None = None, ) -> tuple[list[dict[str, Any]], bool, list[dict[str, Any]]]: """Render the frozen reverify worklist items. Returns ``(items, work_seen, filigree_candidates)``. The changed entities are always present (reason ``changed``) so a solo/NO_SNAPSHOT worklist is still non-empty; downstream entities are added when a snapshot exists. + + ``verification_for`` (advisory, Rung 2 Track B) maps an ``entity_key_id`` to + its verification-freshness block; ``changed_key_ids`` / ``affected_key_ids`` + are aligned 1:1 with ``changed`` / ``affected`` so the block can be attached + without threading the internal key id into the FROZEN ``{locator, sei}`` + entity view. When ``verification_for`` is None the block defaults to an + honest ``unverified`` (no source configured). """ - rows: list[tuple[dict[str, Any], str, int, list[Any]]] = [] - for entry in changed: - rows.append((entry.get("entity", {}), "changed", 0, [])) - for entry in affected: + ckids = changed_key_ids or [None] * len(changed) + akids = affected_key_ids or [None] * len(affected) + rows: list[tuple[dict[str, Any], str, int, list[Any], int | None]] = [] + for entry, kid in zip(changed, ckids, strict=True): + rows.append((entry.get("entity", {}), "changed", 0, [], kid)) + for entry, kid in zip(affected, akids, strict=True): rows.append( ( entry.get("entity", {}), "downstream", entry.get("depth", 1), entry.get("via_edges", []), + kid, ) ) items: list[dict[str, Any]] = [] work_seen = False candidates: list[dict[str, Any]] = [] - for entity, reason, depth, why in rows: + for entity, reason_str, depth, why, kid in rows: enrichment = _empty_enrichment() priority = "unknown" sei = entity.get("sei") @@ -65,15 +99,19 @@ def render_reverify_worklist( "entity": entity, } ) + verification = ( + verification_for(kid) if verification_for is not None else _default_verification() + ) items.append( { "entity": entity, "priority": priority, - "reason": reason, + "reason": reason_str, "depth": depth, "why": why, "suggested_verification": _SUGGESTED_VERIFICATION, "enrichment": enrichment, + "verification": verification, } ) return items, work_seen, candidates diff --git a/src/warpline/skills/warpline-workflow/references/contract.md b/src/warpline/skills/warpline-workflow/references/contract.md index 7787efb..a9a5645 100644 --- a/src/warpline/skills/warpline-workflow/references/contract.md +++ b/src/warpline/skills/warpline-workflow/references/contract.md @@ -51,4 +51,8 @@ NOT a federation key; key on `sei` (preferred) or `locator`. - filigree owns work state; warpline reads links, never files/closes/claims. - wardline owns trust policy; warpline re-derives risk as ordering signal, never a clean/allow verdict. wardline absent → `risk: unavailable`, never `clean`. -- legis owns governance + the rename feed; warpline emits advisory impact only. +- legis owns governance + the rename feed; warpline emits advisory impact only. The + `governance` enrichment echoes legis `governance_read.v1` (verified clearances + only): `absent` = "no verified clearance" (conflates ungoverned / unknown-SEI / + actively-blocked), never "ungoverned"; the clearance `content_hash` is echoed, not + re-verified against the current body. diff --git a/src/warpline/skills/warpline-workflow/references/degrade-and-federation.md b/src/warpline/skills/warpline-workflow/references/degrade-and-federation.md index 5458ca0..7287e54 100644 --- a/src/warpline/skills/warpline-workflow/references/degrade-and-federation.md +++ b/src/warpline/skills/warpline-workflow/references/degrade-and-federation.md @@ -27,6 +27,15 @@ Load this when an answer looks thin, or when wiring warpline into a repo. clean/allowed/governed state. - `edges` may also be `stale | partial | skipped`. +`governance` reads legis `governance_read.v1`, which reports **verified clearances +only** (operator override / cleared sign-off). So `governance: absent` means **"no +verified clearance,"** which deliberately conflates *ungoverned*, *unknown-SEI*, and +crucially an entity **actively BLOCKED awaiting sign-off** — the one governance fact +most relevant to "what must I re-verify," and invisible in this channel. `absent` is +therefore **never** "ungoverned." The clearance `content_hash` is echoed verbatim, +not re-derived against the current body — `present` means legis *holds* a clearance, +not that it is fresh. + warpline is deconfliction tooling, not security: every sibling fact is advisory and **never gates**. wardline absent → `risk: unavailable`, never `clean`. diff --git a/src/warpline/snapshot.py b/src/warpline/snapshot.py index 3aea1f7..774d287 100644 --- a/src/warpline/snapshot.py +++ b/src/warpline/snapshot.py @@ -13,6 +13,14 @@ def neighborhood(self, entity: str) -> dict[str, Any]: ... +def _as_int(value: object) -> int: + if isinstance(value, int): + return value + if isinstance(value, str): + return int(value) + raise TypeError(f"expected integer-compatible value, got {type(value).__name__}") + + def _resolve_commit(repo: Path, commit: str | None) -> str: rev = commit if commit is not None else "HEAD" proc = subprocess.run( @@ -56,8 +64,42 @@ def capture_edge_snapshot( repo_id = store.ensure_repo(repo) resolved_commit = _resolve_commit(repo, commit_sha) if client is None: - snapshot_id = record_skipped_snapshot(store, repo_id, resolved_commit, source_version) - store.clear_snapshot_edges(snapshot_id) + prior = store.get_edge_snapshot(repo_id, resolved_commit, "loomweave") + if prior is not None and prior.get("completeness") in {"FULL", "DELTA"}: + # Loomweave is absent at re-capture, but a usable prior snapshot + # already describes this immutable commit. Overwriting it with a + # 0-edge SKIPPED row would destroy a real edge graph to record "we + # don't know" — strictly worse than what we already hold, and the + # same R3 data-loss the atomic capture path was built to prevent. + # Leave the stored row and its edges untouched; report the recapture + # as skipped against the preserved snapshot (fail-closed doctrine). + # A stale FULL/DELTA is still a real graph, so we preserve regardless + # of staleness — the read path downgrades stale completeness on its + # own (PDR-0023). + return { + "query": "capture_snapshot", + "commit_sha": resolved_commit, + "snapshot_id": _as_int(prior["id"]), + "source": "loomweave", + "source_version": prior.get("source_version"), + "completeness": prior.get("completeness"), + "entities": 0, + "edges": 0, + "capped": False, + "recapture_skipped": True, + "enrichment": {"edges": "skipped"}, + } + # No usable prior (none, or a prior already SKIPPED): record the skip in + # ONE transaction via the atomic path. There is nothing to corrupt, and + # this retires the old two-commit (UPSERT then DELETE) non-atomic write. + snapshot_id = store.capture_snapshot_atomic( + repo_id=repo_id, + commit_sha=resolved_commit, + source="loomweave", + source_version=source_version, + completeness="SKIPPED", + edges=[], + ) return { "query": "capture_snapshot", "commit_sha": resolved_commit, diff --git a/src/warpline/store.py b/src/warpline/store.py index d651167..19606fb 100644 --- a/src/warpline/store.py +++ b/src/warpline/store.py @@ -194,6 +194,35 @@ def _migrate_v3_co_change_pairs(conn: sqlite3.Connection) -> None: ) +def _migrate_v4_verification_events(conn: sqlite3.Connection) -> None: + """v4 (Rung 2 Track B): verification-freshness events. + + ``verification_events`` records a per-commit gate-pass fact ("gate ``kind`` + passed as-of commit ``commit_sha``"), one row per run — mirroring + ``change_events``. Freshness is computed at read time by git reachability + (is a change commit an ancestor-or-equal of a verified commit), never by + stamping every entity. Warpline OWNS this fact (its own gate result); it + mirrors no sibling. ``commit_sha`` is always a resolved object SHA, never a + symbolic ref. The UNIQUE key makes a re-record of the same (repo, commit, + kind, source) idempotent. + """ + + conn.execute( + """ + CREATE TABLE IF NOT EXISTS verification_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + repo_id TEXT NOT NULL, + commit_sha TEXT NOT NULL, + kind TEXT NOT NULL, + verified_at TEXT NOT NULL, + actor TEXT, + source TEXT NOT NULL DEFAULT 'warpline', + UNIQUE(repo_id, commit_sha, kind, source) + ) + """ + ) + + # Ordered, forward-only migrations. Each step's ``version`` is strictly greater # than the previous. v2 (anchor columns) lands in Rung 1b; v3 (co_change_pairs) # in Rung 2 Track A. @@ -204,6 +233,7 @@ def _migrate_v3_co_change_pairs(conn: sqlite3.Connection) -> None: MIGRATIONS: list[Migration] = [ Migration(version=2, apply=_migrate_v2_anchor_columns), Migration(version=3, apply=_migrate_v3_co_change_pairs), + Migration(version=4, apply=_migrate_v4_verification_events), ] # Highest schema version this build knows how to produce. Equals the base @@ -219,6 +249,159 @@ def default_store_path(repo: Path, base_dir: Path | None = None) -> Path: return state / "warpline.db" +def store_repo_id(repo: Path) -> str: + """Stable per-repo store key (sha256 of the resolved root). + + Single source of truth for the repo_id derivation used by both + ``WarplineStore._repo_id`` (the writer path) and ``read_store_binding`` (the + read-only probe), so the binding probe scopes its counts to the SAME repo the + writer keyed on. + """ + + return hashlib.sha256(str(repo.resolve()).encode("utf-8")).hexdigest() + + +# Closed vocabulary for ``StoreBinding.status`` — mirrors warpline's other closed +# vocabularies (enrichment, reason classes): an honest status is one of these, +# never a free-form string. ``ok`` = present + readable + serveable schema; +# ``store_absent`` = no DB file; ``store_unreadable`` = corrupt / no meta row / +# unparseable; ``schema_ahead`` = written by a newer build than this one serves. +STORE_STATUS_VOCAB = frozenset({"ok", "store_absent", "store_unreadable", "schema_ahead"}) + + +class StoreBinding(NamedTuple): + """Result of the read-only ``read_store_binding`` probe. + + ``binding_ok`` is the federation-harness verdict: True iff the snapshot store + is present, readable, AND at a schema version THIS build can serve. The + load-bearing field is ``schema_version`` — READ from inside the store, with a + ``None`` sentinel whenever the store is absent, corrupt, or written by a newer + build than this binary can read (the stale-binary case). ``status`` is the + closed ``STORE_STATUS_VOCAB``. + """ + + present: bool + readable: bool + schema_version: int | None + snapshot_rev: str | None + change_event_count: int | None + binding_ok: bool + status: str + detail: str + + +def _binding_unreadable(detail: str) -> StoreBinding: + return StoreBinding( + present=True, + readable=False, + schema_version=None, + snapshot_rev=None, + change_event_count=None, + binding_ok=False, + status="store_unreadable", + detail=detail, + ) + + +def read_store_binding(repo: Path, base_dir: Path | None = None) -> StoreBinding: + """Read-only binding/health probe for warpline's snapshot store. + + Answers "can THIS warpline build read and SERVE the snapshot store for + ``repo``?" — the federation-attachment signal the Lacuna harness asserts on to + catch a stale-but-running warpline whose store schema moved out from under the + binary. Unlike ``WarplineStore.open`` (which mkdir's, ``executescript``'s the + base schema, and runs migrations — *creating* a store on first touch), this + NEVER creates or migrates snapshot STATE: an absent store reports absent (no + file is created), no rows are written, and the durable ``warpline.db`` is left + byte-for-byte unchanged. It opens the DB strictly read-only (``mode=ro``, + which also fails fast on a missing file rather than creating one) and reads + the schema version, a cheap change-event count, and the latest captured + snapshot rev directly. + + Honest caveat: opening a PRESENT WAL-mode store read-only lets SQLite create + its transient coordination sidecars (``warpline.db-wal`` / ``-shm``) — these + are gitignored, not snapshot state, and ``mode=ro`` (over ``immutable=1``) is + deliberate so the probe always reads the latest COMMITTED schema version even + if a writer left un-checkpointed WAL frames (the stale-binary signal must not + itself read stale). + """ + + path = default_store_path(repo, base_dir) + if not path.is_file(): + return StoreBinding( + present=False, + readable=False, + schema_version=None, + snapshot_rev=None, + change_event_count=None, + binding_ok=False, + status="store_absent", + detail=f"no warpline snapshot store at {path}; run capture_snapshot", + ) + + try: + conn = sqlite3.connect(f"{path.as_uri()}?mode=ro", uri=True) + except sqlite3.Error as exc: + return _binding_unreadable(f"could not open snapshot store read-only: {exc}") + + conn.row_factory = sqlite3.Row + try: + meta_row = conn.execute( + "SELECT value FROM meta WHERE key = 'schema_version'" + ).fetchone() + user_version = int(conn.execute("PRAGMA user_version").fetchone()[0]) + if meta_row is None: + return _binding_unreadable("snapshot store has no meta.schema_version row") + # The on-disk version is the canonical meta marker, floored by the + # PRAGMA user_version so a writer that bumped either signal is detected. + on_disk = max(int(meta_row["value"]), user_version) + if on_disk > HIGHEST_KNOWN_VERSION: + # The stale-binary case: a newer warpline wrote this store at a schema + # beyond what this build knows. warpline's own open() keeps reads + # "safe when ahead", but the harness question is narrower — "can THIS + # build SERVE it?" — and the honest answer is no. + return StoreBinding( + present=True, + readable=False, + schema_version=None, + snapshot_rev=None, + change_event_count=None, + binding_ok=False, + status="schema_ahead", + detail=( + f"on-disk schema {on_disk} exceeds the highest version this " + f"build serves ({HIGHEST_KNOWN_VERSION}); this warpline is older " + f"than the writer and cannot serve the store" + ), + ) + repo_id = store_repo_id(repo) + count = int( + conn.execute( + "SELECT COUNT(*) FROM change_events WHERE repo_id = ?", (repo_id,) + ).fetchone()[0] + ) + snap_row = conn.execute( + "SELECT commit_sha FROM edge_snapshots WHERE repo_id = ? " + "ORDER BY id DESC LIMIT 1", + (repo_id,), + ).fetchone() + snapshot_rev = str(snap_row["commit_sha"]) if snap_row is not None else None + return StoreBinding( + present=True, + readable=True, + schema_version=on_disk, + snapshot_rev=snapshot_rev, + change_event_count=count, + binding_ok=True, + status="ok", + detail=f"snapshot store readable and serveable at schema {on_disk}", + ) + except (sqlite3.Error, ValueError, TypeError) as exc: + return _binding_unreadable(f"snapshot store unreadable: {exc}") + finally: + conn.close() + + def _ensure_store_gitignore(store_dir: Path) -> None: gitignore = store_dir / ".gitignore" if not gitignore.exists(): @@ -298,7 +481,7 @@ def _schema_presence_floor(conn: sqlite3.Connection, claimed: int) -> int: - Every checkable (≤ HIGHEST_KNOWN) object is present → the marker is TRUSTED and ``claimed`` is returned UNCHANGED. A genuinely-newer DB (``claimed`` > - HIGHEST_KNOWN whose extra v(N>3) objects we cannot enumerate) keeps its + HIGHEST_KNOWN whose extra v(N>4) objects we cannot enumerate) keeps its ahead marker so the ``> HIGHEST_KNOWN`` branch still fires SCHEMA_VERSION_AHEAD. - ``claimed`` below a check simply skips that check. @@ -316,6 +499,11 @@ def _schema_presence_floor(conn: sqlite3.Connection, claimed: int) -> int: if not _table_exists(conn, "co_change_pairs"): return floor floor = 3 + # v4 (Rung 2 Track B): the verification_events table. + if claimed >= 4: + if not _table_exists(conn, "verification_events"): + return floor + floor = 4 # All checkable objects present: trust the marker as-is (never DOWNGRADE a # legitimately-ahead version we simply cannot fully verify). return claimed @@ -488,7 +676,7 @@ def schema_version(self) -> int: return int(row["value"]) def _repo_id(self, repo: Path) -> str: - return hashlib.sha256(str(repo.resolve()).encode("utf-8")).hexdigest() + return store_repo_id(repo) def ensure_repo(self, repo: Path) -> str: repo_id = self._repo_id(repo) @@ -1002,6 +1190,93 @@ def list_change_events( ).fetchall() return [dict(row) for row in rows] + def record_verification_event( + self, + *, + repo_id: str, + commit_sha: str, + kind: str, + verified_at: str, + actor: str | None, + source: str = "warpline", + ) -> bool: + """Record one gate-pass fact. Idempotent on (repo, commit, kind, source). + + Returns True if a NEW row was inserted, False if an identical event + already existed (the ``INSERT OR IGNORE`` was a no-op). This gives the + verb an O(1), race-free idempotency signal without a second table scan. + ``commit_sha`` must be a resolved object SHA (the caller resolves the ref). + """ + + cursor = self.conn.execute( + """ + INSERT OR IGNORE INTO verification_events( + repo_id, commit_sha, kind, verified_at, actor, source + ) VALUES (?, ?, ?, ?, ?, ?) + """, + (repo_id, commit_sha, kind, verified_at, actor, source), + ) + inserted = cursor.rowcount > 0 + self.conn.commit() + return inserted + + def list_verification_events(self, repo: Path) -> list[dict[str, object]]: + """All verification events for ``repo``, ordered oldest-first by verified_at. + + ``verified_at`` is ISO-8601 written by the verb. We do NOT lexical-sort: + a caller-supplied ``now`` could carry a non-UTC offset, and a + chronologically-later ``...-04:00`` value sorts lexically BEFORE a UTC + ``...+00:00`` one — which would corrupt ``compose_verification_freshness``'s + most-recent-covering-event identification. So we normalize to the UTC + instant with ``datetime()`` (mirroring ``list_change_events`` at + ``store.py:~999``), and COALESCE back to the raw string so a value + ``datetime()`` cannot parse still sorts deterministically by its lexical + form rather than vanishing. ``id`` is the final tiebreak. + """ + + repo_id = self._repo_id(repo) + rows = self.conn.execute( + """ + SELECT commit_sha, kind, verified_at, actor, source + FROM verification_events + WHERE repo_id = ? + ORDER BY COALESCE(datetime(verified_at), verified_at), id + """, + (repo_id,), + ).fetchall() + return [dict(row) for row in rows] + + def list_change_events_for_key_ids( + self, repo: Path, key_ids: list[int] + ) -> list[dict[str, object]]: + """Change events filtered to ``key_ids`` (reverify's verification path). + + Pushes the entity filter into SQL (``WHERE ce.entity_key_id IN (...)``) + so reverify does not full-table-scan every change event in the repo just + to group commits by the handful of entities in the worklist. Empty + ``key_ids`` short-circuits to ``[]``. Returns the same row shape as + ``list_change_events`` (carries ``entity_key_id``, ``commit_sha``, + ``changed_at``), ordered oldest-first by the normalized ``changed_at`` + instant then ``id`` so callers can take the latest change as the last row. + """ + + if not key_ids: + return [] + repo_id = self._repo_id(repo) + unique_ids = sorted(set(key_ids)) + placeholders = ",".join("?" for _ in unique_ids) + rows = self.conn.execute( + f""" + SELECT ce.commit_sha, ce.changed_at, ce.entity_key_id + FROM change_events ce + WHERE ce.repo_id = ? + AND ce.entity_key_id IN ({placeholders}) + ORDER BY COALESCE(datetime(ce.changed_at), ce.changed_at), ce.id + """, + (repo_id, *unique_ids), + ).fetchall() + return [dict(row) for row in rows] + def timeline(self, repo: Path, entity: str) -> list[dict[str, object]]: repo_id = self._repo_id(repo) rows = self.conn.execute( @@ -1540,6 +1815,28 @@ def capture_snapshot_atomic( self.conn.execute("ROLLBACK") raise + def get_edge_snapshot( + self, repo_id: str, commit_sha: str, source: str + ) -> dict[str, object] | None: + """Fetch the snapshot row for an exact ``(repo, commit, source)`` key. + + The UPSERT key is ``(repo_id, commit_sha, source)``, so this returns the + at-most-one row that a recapture for that triple would overwrite — the + precondition the loomweave-absent path needs to decide whether a usable + prior already exists (vs. ``latest_snapshot``, which is repo-latest by id + and answers a different question). + """ + + row = self.conn.execute( + """ + SELECT id, commit_sha, source, source_version, captured_at, completeness + FROM edge_snapshots + WHERE repo_id = ? AND commit_sha = ? AND source = ? + """, + (repo_id, commit_sha, source), + ).fetchone() + return dict(row) if row is not None else None + def latest_snapshot(self, repo: Path) -> dict[str, object] | None: repo_id = self._repo_id(repo) row = self.conn.execute( diff --git a/src/warpline/verification.py b/src/warpline/verification.py new file mode 100644 index 0000000..35fa3d7 --- /dev/null +++ b/src/warpline/verification.py @@ -0,0 +1,180 @@ +"""Pure verification-freshness compute (internal API). + +Mirrors ``_enrichment.py``: enrich-only, no store, no git, no I/O — git +reachability is injected as the ``covers`` / ``commits_between`` callables. The +import list (``collections.abc`` + ``typing`` + ``warpline.listing.reason``) is +the structural proof that this module cannot gate, mirror a sibling, or perform +I/O. + +Freshness asks: has the entity's LATEST change been proven good by a recorded +gate run? A gate run at commit ``V`` "covers" a change at commit ``C`` iff ``C`` +is an ancestor-or-equal of ``V`` (the gate ran at or after the change landed). +Absence is always EXPLAINED via a weft-reason triple; it never reads as verified. + +For the STALE path, decay uses the TIGHTEST git cover — the covering event whose +commit is fewest commits behind the latest change (the most-advanced proof +available) — not the most-recently-recorded covering event. This ensures +``decay.commits_behind`` reflects the best verification already on record, +regardless of the order in which events were written. +""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +from warpline.listing import reason + + +def _tightest_covering_event( + change_commits: list[str], + events: list[dict[str, Any]], + covers: Callable[[str, str], bool | None], + commits_between: Callable[[str, str], int | None], + latest_change: str, +) -> tuple[dict[str, Any] | None, int | None, bool]: + """Return (tightest covering event, its commits_behind, saw_undetermined). + + A "covering" event covers at least one of ``change_commits``. Among them, pick + the TIGHTEST cover — the one whose commit is fewest commits behind + ``latest_change`` (minimal ``commits_between(event_commit, latest_change)``) — + so decay reflects the most-advanced proof, not merely the most-recently + recorded. ``events`` is oldest-first; ties on distance break toward the most + recent ``verified_at`` (later iteration). If covering events exist but none has + a computable distance, fall back to the most-recent covering event with a None + decay. ``saw_undetermined`` is True if any ``covers`` call returned None. + """ + best_event: dict[str, Any] | None = None + best_dist: int | None = None + fallback_event: dict[str, Any] | None = None + saw_undetermined = False + for event in events: + verified_commit = str(event.get("commit_sha")) + covers_any = False + for change_commit in change_commits: + result = covers(verified_commit, change_commit) + if result is None: + saw_undetermined = True + elif result is True: + covers_any = True + break + if not covers_any: + continue + fallback_event = event # oldest-first -> last covering wins as fallback + dist = commits_between(verified_commit, latest_change) + if dist is None: + continue + if best_dist is None or dist <= best_dist: # tie -> most recent (later) wins + best_dist = dist + best_event = event + if best_event is not None: + return best_event, best_dist, saw_undetermined + return fallback_event, None, saw_undetermined + + +def compose_verification_freshness( + entity_change_commits: list[str], + verification_events: list[dict[str, Any]], + covers: Callable[[str, str], bool | None], + commits_between: Callable[[str, str], int | None], +) -> dict[str, Any]: + """Compose the per-entity verification-freshness block. See module docstring.""" + + if not entity_change_commits: + return _unverified("the entity has no recorded change commits to verify") + + latest_change = entity_change_commits[-1] # oldest-first input -> latest is last + + # Is the LATEST change covered by any event? (fresh wins outright.) + latest_saw_undetermined = False + fresh_event: dict[str, Any] | None = None + for event in verification_events: + result = covers(str(event.get("commit_sha")), latest_change) + if result is None: + latest_saw_undetermined = True + elif result is True: + fresh_event = event # most-recent covering event wins (oldest-first) + + if fresh_event is not None: + return { + "state": "fresh", + "last_verified_at": fresh_event.get("verified_at"), + "last_verified_commit": fresh_event.get("commit_sha"), + "decay": {"commits_behind": 0}, + "reason": reason("clean"), + } + + # Not fresh. If git could not decide the latest-change coverage, fail soft. + if latest_saw_undetermined: + return _unavailable() + + # Latest definitively uncovered (all covers() returned False, no None — else + # we'd have returned unavailable above). Does any event cover an EARLIER + # change? Check only [:-1] — the latest is already known uncovered, so + # re-checking it would waste a covers() call. + covering_event, commits_behind, earlier_undetermined = _tightest_covering_event( + entity_change_commits[:-1], verification_events, covers, commits_between, latest_change + ) + if covering_event is not None: + return { + "state": "stale", + "last_verified_at": covering_event.get("verified_at"), + "last_verified_commit": covering_event.get("commit_sha"), + "decay": {"commits_behind": commits_behind}, + "reason": reason( + "stale", + cause=( + "the entity changed since it was last proven good: its latest change " + "commit is not covered by any recorded verification event" + ), + fix=( + "re-run your gate (tests/CI) at HEAD and record it with " + "`warpline verify-record --commit HEAD --kind test_pass`" + ), + ), + } + + if earlier_undetermined: + return _unavailable() + return _unverified( + "no recorded verification event covers any of the entity's change commits" + ) + + +def _unverified(cause: str) -> dict[str, Any]: + return { + "state": "unverified", + "last_verified_at": None, + "last_verified_commit": None, + "decay": {"commits_behind": None}, + "reason": reason( + "disabled", + cause=cause, + fix=( + "record a gate pass after your tests/CI run with " + "`warpline verify-record --commit --kind test_pass`; until then " + "verification is honestly unverified, not an earned-clean" + ), + ), + } + + +def _unavailable() -> dict[str, Any]: + return { + "state": "unavailable", + "last_verified_at": None, + "last_verified_commit": None, + "decay": {"commits_behind": None}, + "reason": reason( + "unreachable", + cause=( + "git reachability between the entity's change commits and the recorded " + "verification commits could not be computed (e.g. shallow clone or a " + "missing commit object)" + ), + fix=( + "fetch full history (unshallow the clone) so commit ancestry is " + "resolvable, then re-query; until then freshness is honestly unavailable" + ), + ), + } diff --git a/tests/contracts/test_golden_vectors.py b/tests/contracts/test_golden_vectors.py index 246ec35..2762106 100644 --- a/tests/contracts/test_golden_vectors.py +++ b/tests/contracts/test_golden_vectors.py @@ -1,4 +1,4 @@ -"""The 18 FROZEN golden vectors (interface-lock §1D, 2C, 3C, 4C). +"""The 19 FROZEN golden vectors (interface-lock §1D, 2C, 3C, 4C). These are warpline's contribution as the 5th producer to the four-member conformance oracle (GS-7). Each test is one frozen (input → output assertion) @@ -70,6 +70,20 @@ def _add_change( ) +def _commit_file(repo: Path, name: str, body: str) -> str: + """Write *name* to *repo*, git-add, commit, and return the resolved HEAD SHA.""" + (repo / name).write_text(body) + subprocess.run(["git", "add", "."], cwd=repo, check=True, capture_output=True) + subprocess.run( + ["git", "commit", "-q", "-m", f"touch {name}"], + cwd=repo, check=True, capture_output=True, + ) + return subprocess.run( + ["git", "rev-parse", "HEAD"], + cwd=repo, check=True, text=True, capture_output=True, + ).stdout.strip() + + class _FullNeighborhoodClient: def neighborhood(self, entity: str) -> dict[str, Any]: if entity == "python:function:pkg.mod.a": @@ -176,7 +190,13 @@ def test_gv_lw_3_capture_full_then_skipped(tmp_path: Path) -> None: assert full["completeness"] == "FULL" assert full["edges"] > 0 - skipped = commands.capture_snapshot(repo, commit="c1", loomweave_command="/no/such/loomweave") + # The frozen contract is "loomweave absent -> SKIPPED" for a commit with no + # usable prior. Exercise it at a DISTINCT commit (c2), not c1: re-capturing + # c1 (which already holds the FULL above) must PRESERVE that snapshot, never + # downgrade it to a 0-edge SKIPPED row — that is the GV-LW-6 fail-closed + # doctrine (a loomweave-absent recapture is the same data-loss class as a + # mid-capture kill), and is locked by test_capture_skipped_preserves_prior_*. + skipped = commands.capture_snapshot(repo, commit="c2", loomweave_command="/no/such/loomweave") assert skipped["data"]["completeness"] == "SKIPPED" assert skipped["data"]["edges"] == 0 assert skipped["enrichment"]["edges"] == "skipped" @@ -482,3 +502,66 @@ def test_gv_hon_req_requirements_is_reserved_but_honest_on_every_tool(tmp_path: assert triple["cause"] assert "reserved" in triple["cause"].lower() assert triple["fix"] + + +# ============================================================ SEAM 5 — verification freshness +def test_gv_vf_1_reverify_verification_freshness_is_explained(tmp_path: Path) -> None: + """GV-VF-1: the reverify worklist carries an HONEST verification block. + + Locks: (a) unverified-when-no-source — every item reads ``unverified`` with a + ``disabled`` reason when no gate pass is recorded; (b) ``fresh`` once the + change is verified; (c) the never-filter invariant — recording verification + annotates/sorts but never removes an item; (d) verification rides the data + block, never the FROZEN enrichment vocab. + """ + + repo = _git_repo(tmp_path) + # One real commit so verify-record can resolve HEAD to an object SHA. + head = _commit_file(repo, "m.py", "v0\n") + with _store(repo) as store: + repo_id = store.ensure_repo(repo) + key_id = store.ensure_entity_key(repo_id, "python:function:m.py::f", None, head) + store.append_change_event( + repo_id=repo_id, entity_key_id=key_id, commit_sha=head, path="m.py", + change_kind="modified", actor="dev", changed_at="2026-06-25T08:00:00+00:00", + ) + + # (a) No verification recorded yet -> unverified + explained. + env = commands.reverify_worklist(repo, [key_id]) + summary = env["data"]["verification_summary"] + assert summary["local_source_configured"] is False + assert summary["unverified"] >= 1 + assert env["data"]["items"], "expected a non-empty worklist" + n_items = len(env["data"]["items"]) + item = env["data"]["items"][0] + assert item["verification"]["state"] == "unverified" + assert item["verification"]["reason"]["reason_class"] == "disabled" + assert item["verification"]["reason"]["cause"] and item["verification"]["reason"]["fix"] + # (d) verification is NOT in the frozen enrichment vocab. + assert "verification" not in env["enrichment"] + assert "verification" not in env["enrichment_reasons"] + # Honesty meta preserved on the pre-verify envelope too. + assert env["meta"]["local_only"] is True + assert env["meta"]["peer_side_effects"] == [] + + # (b) record a gate pass at HEAD -> fresh. + commands.verify_record(repo, commit=head, kind="test_pass", now="2026-06-25T10:00:00+00:00") + env2 = commands.reverify_worklist(repo, [key_id]) + assert env2["data"]["verification_summary"]["local_source_configured"] is True + assert env2["data"]["verification_summary"]["fresh"] >= 1 + assert env2["data"]["items"], "expected a non-empty worklist after verification" + assert any(i["reason"] == "changed" for i in env2["data"]["items"]) + fresh_item = next(i for i in env2["data"]["items"] if i["reason"] == "changed") + assert fresh_item["verification"]["state"] == "fresh" + assert fresh_item["verification"]["last_verified_commit"] == head + + # (c) never-filter is an IDENTITY invariant, not just cardinality: the exact + # SET of entities is unchanged by recording verification (count-equality alone + # would pass a buggy impl that drops one item and re-adds a different one). + assert len(env2["data"]["items"]) == n_items + before_locators = {i["entity"]["locator"] for i in env["data"]["items"]} + after_locators = {i["entity"]["locator"] for i in env2["data"]["items"]} + assert after_locators == before_locators + # Honesty meta preserved. + assert env2["meta"]["local_only"] is True + assert env2["meta"]["peer_side_effects"] == [] diff --git a/tests/contracts/test_governance_read_schema.py b/tests/contracts/test_governance_read_schema.py new file mode 100644 index 0000000..9795b4e --- /dev/null +++ b/tests/contracts/test_governance_read_schema.py @@ -0,0 +1,179 @@ +"""legis governance_read.v1 — warpline's mirrored consumer contract. + +legis OWNS this contract; warpline mirrors it BYTE-FOR-BYTE at +``contracts/governance_read.v1.schema.json`` as the source of truth for its +advisory ``LegisGovernanceClient``. Two vector sources: the contract's CANONICAL +LITERAL SAMPLES (from the legis-authored spec, exercising every union arm), and a +REAL legis-produced envelope vendored verbatim from legis's conformance golden +(``legis-governance-read.golden.json``) — the interface-agreement guard that fails +loud if legis's emitted shape and warpline's consumed shape ever diverge. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +jsonschema = pytest.importorskip("jsonschema") + +_ROOT = Path(__file__).resolve().parents[2] +SCHEMA_PATH = _ROOT / "contracts" / "governance_read.v1.schema.json" + + +def _schema() -> dict: + return json.loads(SCHEMA_PATH.read_text(encoding="utf-8")) + + +def _validate(instance: dict) -> None: + jsonschema.validate(instance=instance, schema=_schema()) + + +def _rejects(instance: dict) -> None: + with pytest.raises(jsonschema.ValidationError): + _validate(instance) + + +# --- canonical contract samples (legis-authored; not a live capture) ---------- +CHECKED_WITH_CLEARANCES = { + "status": "checked", + "sei": "loomweave:eid:7Q3fc1", + "records": [ + { + "sei": "loomweave:eid:7Q3fc1", + "disposition": "cleared", + "posture": "protected_override", + "authority": "operator", + "as_of": "2026-06-27T14:02:11Z", + "reasons": ["operator_override"], + "content_hash": "b3:9f2ce7", + }, + { + "sei": "loomweave:eid:7Q3fc1", + "disposition": "cleared", + "posture": "operator_signoff", + "authority": "operator", + "as_of": "2026-06-26T09:41:55+00:00", + "reasons": ["signoff_cleared"], + "content_hash": "b3:5a1092", + }, + ], +} +CHECKED_EMPTY = {"status": "checked", "sei": "loomweave:eid:unknown", "records": []} +UNAVAILABLE = { + "status": "unavailable", + "sei": "loomweave:eid:7Q3fc1", + "records": [], + "unavailable": [{"reason": "trail not signature-verifiable (no protected gate / verifier)"}], +} + + +_GOLDEN_PATH = _ROOT / "tests" / "fixtures" / "contracts" / "warpline" / ( + "legis-governance-read.golden.json" +) + + +def test_schema_is_wellformed_draft_2020_12() -> None: + jsonschema.Draft202012Validator.check_schema(_schema()) + + +def test_real_legis_golden_envelope_validates_and_parses() -> None: + """Cross-member conformance: a REAL legis-produced governance_read.v1 envelope + (vendored verbatim from legis's conformance golden) validates against warpline's + mirrored schema AND round-trips through the consumer's parse contract. This is + the interface-agreement guard — it fails loud if legis's emitted shape and + warpline's consumed shape ever diverge.""" + + golden = json.loads(_GOLDEN_PATH.read_text(encoding="utf-8")) + _validate(golden) + assert golden["status"] == "checked" + records = [r for r in golden["records"] if isinstance(r, dict)] + assert records, "golden carries at least one verified clearance" + assert {r["disposition"] for r in records} == {"cleared"} + assert {r["posture"] for r in records} <= {"protected_override", "operator_signoff"} + + +def test_canonical_samples_validate() -> None: + _validate(CHECKED_WITH_CLEARANCES) + _validate(CHECKED_EMPTY) # earned-empty: no verified clearance, NOT "ungoverned" + _validate(UNAVAILABLE) + + +def test_both_postures_and_reason_codes_round_trip() -> None: + postures = {r["posture"] for r in CHECKED_WITH_CLEARANCES["records"]} + reasons = {c for r in CHECKED_WITH_CLEARANCES["records"] for c in r["reasons"]} + assert postures == {"protected_override", "operator_signoff"} + assert reasons == {"operator_override", "signoff_cleared"} + + +# --- rejections: the schema must be tight, not permissive --------------------- +def test_rejects_record_missing_content_hash() -> None: + bad = json.loads(json.dumps(CHECKED_WITH_CLEARANCES)) + del bad["records"][0]["content_hash"] + _rejects(bad) + + +def test_rejects_non_cleared_disposition() -> None: + # A BLOCKED/pending record has no place in v1 (cleared-only); the schema + # enforces the cleared-only scope so a future drift cannot smuggle in-flight + # governance through this channel without a v2 bump. + bad = json.loads(json.dumps(CHECKED_WITH_CLEARANCES)) + bad["records"][0]["disposition"] = "blocked" + _rejects(bad) + + +def test_rejects_status_outside_enum() -> None: + _rejects({"status": "cleared", "sei": "loomweave:eid:x", "records": []}) + + +def test_rejects_unknown_posture() -> None: + bad = json.loads(json.dumps(CHECKED_WITH_CLEARANCES)) + bad["records"][0]["posture"] = "structured" + _rejects(bad) + + +def test_rejects_empty_reasons() -> None: + bad = json.loads(json.dumps(CHECKED_WITH_CLEARANCES)) + bad["records"][0]["reasons"] = [] + _rejects(bad) + + +def test_rejects_unknown_top_level_key() -> None: + bad = json.loads(json.dumps(CHECKED_EMPTY)) + bad["verdict"] = "allow" # no verdict leaks through a governance READ + _rejects(bad) + + +# --- discriminated union (legis hardened the contract; backward-compatible) ---- +# These pin the status<->shape coupling the `allOf` enforces, so warpline's +# consumer validates with EXACTLY the tightness legis emits — an 'unavailable' +# that could masquerade as a clean/empty 'checked' is the false-green this kills. +def test_rejects_checked_carrying_an_unavailable_key() -> None: + # status 'checked' MUST NOT carry the unavailable reasons array. + _rejects({"status": "checked", "sei": "loomweave:eid:x", "records": [], + "unavailable": [{"reason": "leaked"}]}) + + +def test_rejects_unavailable_missing_its_reasons() -> None: + # status 'unavailable' REQUIRES the unavailable array. + _rejects({"status": "unavailable", "sei": "loomweave:eid:x", "records": []}) + + +def test_rejects_unavailable_with_empty_reasons() -> None: + # the unavailable array must be non-empty (minItems: 1) — never a silent empty. + _rejects({"status": "unavailable", "sei": "loomweave:eid:x", "records": [], + "unavailable": []}) + + +def test_rejects_unavailable_with_a_blank_reason_string() -> None: + _rejects({"status": "unavailable", "sei": "loomweave:eid:x", "records": [], + "unavailable": [{"reason": ""}]}) + + +def test_rejects_unavailable_carrying_records() -> None: + # 'unavailable' must carry [] records (maxItems: 0) — no clearance rides an + # unverifiable answer. + bad = json.loads(json.dumps(UNAVAILABLE)) + bad["records"] = CHECKED_WITH_CLEARANCES["records"] + _rejects(bad) diff --git a/tests/contracts/test_reverify_worklist_schema.py b/tests/contracts/test_reverify_worklist_schema.py new file mode 100644 index 0000000..43f18b5 --- /dev/null +++ b/tests/contracts/test_reverify_worklist_schema.py @@ -0,0 +1,368 @@ +"""The published reverify-worklist contract artifact (federation D1). + +These tests are warpline's half of the drift-checkable seam with wardline: + + * the artifact at ``contracts/reverify_worklist.v1.schema.json`` is a valid + JSON Schema, and + * REAL warpline worklist output (NO_SNAPSHOT / complete / partial) validates + against it, including the additive ``impact_completeness`` object and + ``generated_at``, and + * a round-tripped partial worklist degrades warpline's own consumer-side risk + path to ``risk=unavailable(completeness_partial)`` — never clean. + +wardline's existence-gated conformance test +(``test_vendored_worklist_matches_published_artifact``) finds this artifact at the +exact path above and will tighten to validate its vendored fixtures against it. +""" + +from __future__ import annotations + +import json +import subprocess +from pathlib import Path + +import pytest + +from warpline import commands +from warpline._completeness import completeness_risk +from warpline.store import WarplineStore, default_store_path + +jsonschema = pytest.importorskip("jsonschema") + +_ROOT = Path(__file__).resolve().parents[2] +SCHEMA_PATH = _ROOT / "contracts" / "reverify_worklist.v1.schema.json" +FIXTURE_PATH = ( + _ROOT / "tests" / "fixtures" / "contracts" / "warpline" / "mcp-response-reverify.json" +) + + +def _schema() -> dict: + return json.loads(SCHEMA_PATH.read_text(encoding="utf-8")) + + +def _validate(instance: dict) -> None: + jsonschema.validate(instance=instance, schema=_schema()) + + +# --------------------------------------------------------------------------- repo helpers +def _git(repo: Path, *args: str) -> str: + return subprocess.run( + ["git", *args], cwd=repo, check=True, text=True, capture_output=True + ).stdout.strip() + + +def _repo(tmp_path: Path) -> Path: + repo = tmp_path / "repo" + repo.mkdir() + _git(repo, "init") + _git(repo, "config", "user.email", "agent@example.test") + _git(repo, "config", "user.name", "agent") + (repo / "seed.txt").write_text("seed\n", encoding="utf-8") + _git(repo, "add", ".") + _git(repo, "commit", "-m", "seed") + return repo + + +def _no_snapshot_worklist(tmp_path: Path) -> dict: + repo = _repo(tmp_path) + with WarplineStore.open(default_store_path(repo)) as store: + repo_id = store.ensure_repo(repo) + a = store.ensure_entity_key( + repo_id, locator="python:function:m.py::a", sei="loomweave:eid:aaaa", commit_sha="c1" + ) + # No snapshot, no loomweave -> NO_SNAPSHOT (lazy capture is a no-op fail-soft). + return commands.reverify_worklist(repo, [a], depth=2, loomweave_command="/no/such/binary") + + +def _seed_complete(tmp_path: Path) -> tuple[Path, int]: + """A FULL snapshot AT HEAD with no out-edges -> fresh graph, no depth cap, zero + unresolved -> impact_completeness.status == complete. Returns (repo, a).""" + repo = _repo(tmp_path) + head = _git(repo, "rev-parse", "HEAD") + with WarplineStore.open(default_store_path(repo)) as store: + repo_id = store.ensure_repo(repo) + a = store.ensure_entity_key( + repo_id, locator="python:function:m.py::a", sei="loomweave:eid:aaaa", commit_sha=head + ) + store.create_edge_snapshot(repo_id, head, "loomweave", "t", "FULL") + return repo, a + + +def _complete_worklist(tmp_path: Path) -> dict: + repo, a = _seed_complete(tmp_path) + return commands.reverify_worklist(repo, [a], depth=2) + + +def _seed_partial(tmp_path: Path) -> tuple[Path, int]: + """A FULL snapshot AT HEAD over an a->b->c chain. Returns (repo, a). Querying + at depth=1 truncates the chain at b -> depth_capped -> status partial.""" + repo = _repo(tmp_path) + head = _git(repo, "rev-parse", "HEAD") + with WarplineStore.open(default_store_path(repo)) as store: + repo_id = store.ensure_repo(repo) + a = store.ensure_entity_key( + repo_id, locator="python:function:m.py::a", sei="loomweave:eid:aaaa", commit_sha=head + ) + b = store.ensure_entity_key( + repo_id, locator="python:function:m.py::b", sei="loomweave:eid:bbbb", commit_sha=head + ) + c = store.ensure_entity_key( + repo_id, locator="python:function:m.py::c", sei="loomweave:eid:cccc", commit_sha=head + ) + snap = store.create_edge_snapshot(repo_id, head, "loomweave", "t", "FULL") + for src, dst in ((a, b), (b, c)): + store.append_snapshot_edge( + snap, source_entity_key_id=src, target_entity_key_id=dst, + edge_kind="calls", confidence="resolved", + ) + return repo, a + + +def _partial_worklist(tmp_path: Path) -> dict: + repo, a = _seed_partial(tmp_path) + return commands.reverify_worklist(repo, [a], depth=1) + + +# --------------------------------------------------------------------------- schema tests +def test_published_schema_is_itself_valid_jsonschema() -> None: + jsonschema.Draft202012Validator.check_schema(_schema()) + + +def test_vendored_fixture_validates_against_published_schema() -> None: + _validate(json.loads(FIXTURE_PATH.read_text(encoding="utf-8"))) + + +def test_real_no_snapshot_output_validates_and_is_unknown(tmp_path: Path) -> None: + env = _no_snapshot_worklist(tmp_path) + _validate(env) + ic = env["data"]["impact_completeness"] + assert env["data"]["completeness"] == "NO_SNAPSHOT" + assert ic["status"] == "unknown" + assert ic["graph_ref"] is None + assert "no_snapshot" in ic["reasons"] + # the producer timestamp (staleness axis) lives INSIDE the object, not at data.* + assert isinstance(ic["as_of"], str) + assert "generated_at" not in env["data"] + + +def test_real_complete_output_validates_and_is_complete(tmp_path: Path) -> None: + env = _complete_worklist(tmp_path) + _validate(env) + ic = env["data"]["impact_completeness"] + assert ic["status"] == "complete" + assert ic["graph_fresh"] is True + assert ic["depth_capped"] is False + assert ic["unresolved_count"] == 0 + assert ic["reasons"] == [] + # Rung 2: complete but NO attest bundle supplied -> the honest gap, on the + # real command path (not just the pure consumer). + assert env["data"]["risk_verification"]["risk"] == "unavailable" + assert env["data"]["risk_verification"]["reason_code"] == "verification_source_absent" + + +def _matching_bundle(commit: str, sei: str, content_hash: str) -> dict: + return { + "schema": "wardline-attest-2", + "payload": { + "commit": commit, "dirty": False, "attested_at": "2026-06-27", + "sei_source": "loomweave", "posture": {}, + "boundaries": [{"qualname": "x", "sei": sei, "content_hash": content_hash, + "verdict": "clean", "tier": "INTEGRAL"}], + }, + "signature": {"alg": "HMAC-SHA256", "value": "x", "key_id": "y"}, + } + + +def test_reverify_path_reads_proven_good_through_the_full_envelope(tmp_path, monkeypatch) -> None: + # The feature's whole point, exercised through reverify_worklist + build_envelope + # (not just the pure consumer). TWO complete-worklist entities with limit=1 so + # one falls OFF page 1 — this is the regression guard for the paged-vs-full + # sei->locator bug: proven is all-or-nothing over the FULL set, so if the + # content_hash map were built from the paged items the off-page entity would be + # unmatched and this could never read proven. + from warpline import commands + + repo = _repo(tmp_path) + head = _git(repo, "rev-parse", "HEAD") + with WarplineStore.open(default_store_path(repo)) as store: + repo_id = store.ensure_repo(repo) + a = store.ensure_entity_key( + repo_id, locator="python:function:m.py::a", sei="loomweave:eid:aaaa", commit_sha=head + ) + b = store.ensure_entity_key( + repo_id, locator="python:function:m.py::b", sei="loomweave:eid:bbbb", commit_sha=head + ) + store.create_edge_snapshot(repo_id, head, "loomweave", "t", "FULL") # complete, no edges + + match = "deadbeefmatchingbodyhash" + # Deterministic loomweave: every locator's current body hash is `match`. + monkeypatch.setattr(commands, "resolve_content_hash_for_locator", lambda _c, _loc: match) + bundle = { + "schema": "wardline-attest-2", + "payload": { + "commit": head, "dirty": False, "attested_at": "2026-06-27", + "sei_source": "loomweave", "posture": {}, + "boundaries": [ + {"qualname": "a", "sei": "loomweave:eid:aaaa", "content_hash": match, + "verdict": "clean", "tier": "T"}, + {"qualname": "b", "sei": "loomweave:eid:bbbb", "content_hash": match, + "verdict": "clean", "tier": "T"}, + ], + }, + "signature": {"alg": "HMAC-SHA256", "value": "x", "key_id": "y"}, + } + env = commands.reverify_worklist(repo, [a, b], depth=2, limit=1, attest_bundle=bundle) + _validate(env) # well-formed through build_envelope + rv = env["data"]["risk_verification"] + assert rv["risk"] == "proven", rv + assert rv["reason_code"] == "attested_clean" + assert rv["authority"] == "wardline" + assert rv["signature_verified"] is False + assert rv["matched"] == rv["affected"] == 2 # BOTH entities, despite limit=1 + # the clean carrier rides inside data.risk_verification; the envelope is still + # honest (enrichment_reasons unaffected — warpline declared no clean of its own). + assert env["ok"] is True + assert env["enrichment_reasons"]["requirements"]["reason_class"] == "disabled" + + +def test_reverify_path_consumes_bundle_honestly_without_loomweave(tmp_path: Path) -> None: + # A bundle IS supplied and the worklist is complete, but loomweave is + # unreachable (bad command) -> warpline cannot fetch the current content_hash, + # so the entity is honestly unmatched (attestation_incomplete), NEVER faked-good. + # Proves the bundle is consumed on the real command path + the fail-soft edge. + from warpline import commands + + repo, a = _seed_complete(tmp_path) + head = _git(repo, "rev-parse", "HEAD") + bundle = _matching_bundle(head, "loomweave:eid:aaaa", "somehash") + env = commands.reverify_worklist( + repo, [a], depth=2, attest_bundle=bundle, loomweave_command="/no/such/binary" + ) + _validate(env) + rv = env["data"]["risk_verification"] + assert rv["risk"] == "unavailable" + assert rv["reason_code"] == "attestation_incomplete" + + +def test_real_partial_output_validates_and_is_partial(tmp_path: Path) -> None: + env = _partial_worklist(tmp_path) + _validate(env) + ic = env["data"]["impact_completeness"] + assert ic["status"] == "partial" + assert ic["depth_capped"] is True + assert "depth_capped" in ic["reasons"] + + +def _bad_status(d: dict) -> None: + d["impact_completeness"]["status"] = "exhaustive" # outside the enum + + +def _bad_as_of(d: dict) -> None: + d["impact_completeness"]["as_of"] = "not-a-date" # fails the RFC3339 pattern + + +def _bad_reason(d: dict) -> None: + d["impact_completeness"]["reasons"].append("made_up_code") # outside closed vocab + + +def _missing_required(d: dict) -> None: + d["impact_completeness"].pop("graph_ref") # required key removed + + +def _completeness_as_object(d: dict) -> None: + d["completeness"] = {"obj": 1} # the frozen field must stay a string enum + + +def _entity_missing_sei(d: dict) -> None: + d["items"][0]["entity"].pop("sei") # entity must carry locator + sei + + +@pytest.mark.parametrize( + "mutate", + [ + _bad_status, + _bad_as_of, + _bad_reason, + _missing_required, + _completeness_as_object, + _entity_missing_sei, + ], +) +def test_schema_rejects_malformed_payloads(mutate) -> None: + fixture = json.loads(FIXTURE_PATH.read_text(encoding="utf-8")) + mutate(fixture["data"]) + with pytest.raises(jsonschema.ValidationError): + _validate(fixture) + + +# ---------------------------------------------------------- real emission surfaces (MCP + CLI) +def test_mcp_handler_surface_emits_impact_completeness(tmp_path: Path) -> None: + # wardline consumes the worklist as emitted by the MCP TOOL, not the internal + # commands.* return value. Prove the field survives the handler with no response + # projection stripping it, and that the raw `completeness` string is preserved. + from warpline import mcp + + repo, a = _seed_partial(tmp_path) + env = mcp._h_reverify({"repo": str(repo), "changed_entity_key_ids": [a], "depth": 1}) + _validate(env) + ic = env["data"]["impact_completeness"] + assert ic["status"] == "partial" + assert "as_of" in ic + assert env["data"]["completeness"] == "FULL" # FROZEN raw string still present + # Rung-2 verdict is emitted on the MCP surface; a partial worklist can't be proven. + assert env["data"]["risk_verification"]["reason_code"] == "completeness_partial" + + +def test_mcp_handler_consumes_pushed_attest_bundle(tmp_path: Path) -> None: + # The PUSHED bundle arrives as an MCP arg and is consumed on the handler path. + from warpline import mcp + + repo, a = _seed_complete(tmp_path) + head = _git(repo, "rev-parse", "HEAD") + bundle = _matching_bundle(head, "loomweave:eid:aaaa", "somehash") + env = mcp._h_reverify( + {"repo": str(repo), "changed_entity_key_ids": [a], "depth": 2, "attest_bundle": bundle} + ) + _validate(env) + # bundle consumed (complete worklist), but loomweave can't key 'aaaa' here, so + # the verdict is honest unavailable — NOT verification_source_absent (which would + # mean the bundle was ignored), proving the bundle reached the consumer. + assert env["data"]["risk_verification"]["reason_code"] != "verification_source_absent" + + +def test_cli_surface_emits_impact_completeness(tmp_path: Path, capsys) -> None: + # The documented "hand wardline a worklist" path: `warpline reverify --json`. + from warpline import cli + + repo, a = _seed_partial(tmp_path) + rc = cli.main( + ["reverify", "--repo", str(repo), + "--changed-entity-key-id", str(a), "--depth", "1", "--json"] + ) + assert rc == 0 + env = json.loads(capsys.readouterr().out) + _validate(env) + assert env["data"]["impact_completeness"]["status"] == "partial" + assert env["data"]["completeness"] == "FULL" + + +# --------------------------------------------------------------------------- consumer round-trip +def test_partial_worklist_roundtrip_degrades_risk_to_unavailable(tmp_path: Path) -> None: + env = _partial_worklist(tmp_path) + # Round-trip exactly as a pushed payload would travel: serialize -> parse. + roundtripped = json.loads(json.dumps(env)) + verdict = completeness_risk(roundtripped["data"]["impact_completeness"]) + assert verdict["risk"] == "unavailable" + assert verdict["reason_code"] == "completeness_partial" + assert verdict["reason"]["reason_class"] != "clean" + + +def test_pre_d1_worklist_without_field_degrades_to_not_declared(tmp_path: Path) -> None: + # A worklist from an OLD warpline carries no impact_completeness: the consumer + # must report risk=unavailable(completeness_not_declared), never clean. + env = _complete_worklist(tmp_path) + data = json.loads(json.dumps(env))["data"] + data.pop("impact_completeness", None) + verdict = completeness_risk(data.get("impact_completeness")) + assert verdict["risk"] == "unavailable" + assert verdict["reason_code"] == "completeness_not_declared" diff --git a/tests/contracts/test_warpline_contract_fixtures.py b/tests/contracts/test_warpline_contract_fixtures.py index da9ccf0..628b6bf 100644 --- a/tests/contracts/test_warpline_contract_fixtures.py +++ b/tests/contracts/test_warpline_contract_fixtures.py @@ -4,10 +4,21 @@ from pathlib import Path from warpline.envelope import ENRICHMENT_VOCAB +from warpline.listing import REASON_CLASSES FIXTURES = Path(__file__).resolve().parents[2] / "tests" / "fixtures" / "contracts" / "warpline" -ENVELOPE_KEYS = {"schema", "ok", "query", "data", "warnings", "next_actions", "enrichment", "meta"} +ENVELOPE_KEYS = { + "schema", + "ok", + "query", + "data", + "warnings", + "next_actions", + "enrichment", + "enrichment_reasons", + "meta", +} def load(name: str) -> dict[str, object]: @@ -23,6 +34,21 @@ def _assert_frozen_envelope(fixture: dict[str, object]) -> None: assert set(enrichment) == set(ENRICHMENT_VOCAB) for key, value in enrichment.items(): assert value in ENRICHMENT_VOCAB[key] + # enrichment_reasons mirrors build_envelope's contract (envelope.py:78-94): + # every dimension is in the closed vocab and every value is a listing.reason() + # triple (a canonical reason_class; non-clean carries both cause and fix). The + # reserved-but-honest `requirements` triple rides on EVERY frozen envelope and + # is universally `disabled` (no transport wired) — never a bare unexplained scalar. + reasons = fixture["enrichment_reasons"] + assert isinstance(reasons, dict) + assert "requirements" in reasons + assert reasons["requirements"]["reason_class"] == "disabled" + for dim, carrier in reasons.items(): + assert dim in ENRICHMENT_VOCAB + assert isinstance(carrier, dict) + assert carrier.get("reason_class") in REASON_CLASSES + if carrier["reason_class"] != "clean": + assert carrier.get("cause") and carrier.get("fix") meta = fixture["meta"] assert isinstance(meta, dict) assert meta["local_only"] is True @@ -51,8 +77,11 @@ def test_mcp_tool_inventory_is_agent_first_and_enrich_only() -> None: } <= set(names) for tool in tools: assert isinstance(tool, dict) - is_capture = tool["name"] in {"capture_snapshot", "warpline_edge_snapshot_capture"} - assert tool["mutates"] is is_capture + is_mutating = tool["name"] in { + "capture_snapshot", "warpline_edge_snapshot_capture", + "verify_record", "warpline_verification_record", + } + assert tool["mutates"] is is_mutating assert tool["local_only"] is True assert tool["peer_side_effects"] == [] assert isinstance(tool["read_only"], bool) @@ -63,6 +92,17 @@ def test_mcp_tool_inventory_is_agent_first_and_enrich_only() -> None: assert tool["schema"].startswith("warpline.") and ".draft." not in tool["schema"] assert tool["authority_boundary"] + # Boundary is DELIBERATE, not drift: this admitted-frozen inventory enumerates + # only the local-state-WRITING federation data contracts (every entry asserts + # writes_local_state==True / mutates_paths==[".weft/warpline/"] above). The + # read-only `project_status` binding/health probe (writes_local_state=False, + # mutates_paths=[]) is intentionally NOT a frozen data contract and is excluded. + # Pin that boundary so a future edit adding it here (which would break the + # writes_local_state invariant) or silently dropping the probe is a visible, + # reviewed decision — not an invisible omission hidden by the subset checks. + assert "project_status" not in set(names) + assert "warpline_project_status_get" not in set(names) + def test_changed_response_fixture_is_frozen_envelope() -> None: fixture = load("mcp-response-changed.json") @@ -84,7 +124,37 @@ def test_reverify_response_fixture_carries_honesty_fields() -> None: _assert_frozen_envelope(fixture) data = fixture["data"] assert isinstance(data, dict) + # FROZEN raw snapshot-completeness STRING (unchanged on v1). assert data["completeness"] in {"FULL", "DELTA", "NO_SNAPSHOT", "SKIPPED"} + # Federation D1 (additive v1): the derived impact-completeness OBJECT wardline + # mirrors verbatim into producer_completeness, plus the producer timestamp. + impact = data["impact_completeness"] + assert isinstance(impact, dict) + assert set(impact) == { + "status", + "as_of", + "graph_fresh", + "graph_ref", + "depth_capped", + "unresolved_count", + "reasons", + } + assert impact["status"] in {"complete", "partial", "unknown"} + assert isinstance(impact["as_of"], str) + assert isinstance(impact["graph_fresh"], bool) + assert isinstance(impact["depth_capped"], bool) + assert isinstance(impact["unresolved_count"], int) + assert isinstance(impact["reasons"], list) + # The producer timestamp (staleness axis) lives inside impact_completeness; the + # redundant top-level data.generated_at was removed (federation reads one object). + assert "generated_at" not in data + # Rung-2 risk-as-verification posture is always emitted (here: no bundle -> + # the completeness gate leaves it unavailable, never a warpline clean). + rv = data["risk_verification"] + assert isinstance(rv, dict) + assert rv["risk"] in {"proven", "unavailable"} + assert isinstance(rv["reason_code"], str) + assert rv["reason"]["reason_class"] != "clean" or rv["risk"] == "proven" assert "staleness" in data assert "items" in data # PDR-0023: the resolve join is interrogable — every changed ref lands in diff --git a/tests/fixtures/contracts/warpline/golden-vectors.json b/tests/fixtures/contracts/warpline/golden-vectors.json index 0e6f844..a73c47a 100644 --- a/tests/fixtures/contracts/warpline/golden-vectors.json +++ b/tests/fixtures/contracts/warpline/golden-vectors.json @@ -45,7 +45,9 @@ {"id": "GV-HON-GOV", "seam": "legis", "tool": "warpline_entity_timeline_get", "assert": "timeline governance is explained: rename feed -> governance present + enrichment_reasons.governance clean; no feed -> governance unavailable + reason_class disabled"}, {"id": "GV-HON-REQ", "seam": "all", "tool": "all six", - "assert": "reserved requirements dimension carries a stable disabled triple (reserved, not yet wired) on every tool; scalar stays unavailable"} + "assert": "reserved requirements dimension carries a stable disabled triple (reserved, not yet wired) on every tool; scalar stays unavailable"}, + {"id": "GV-VF-1", "seam": "warpline", "tool": "warpline_reverify_worklist_get / warpline_verification_record", + "assert": "reverify carries an honest verification block on the DATA item (never the frozen enrichment vocab): no source -> every item unverified + disabled triple + local_source_configured false; record a gate pass at the change commit -> fresh + last_verified_commit set + local_source_configured true; recording verification never removes an item (never-filter); meta.local_only true / peer_side_effects []"} ], "reserved_shape_inbound": { "loomweave": "PROVEN+FROZEN (entity_resolve, entity_neighborhood_get) — real consumption (HX1, capture)", diff --git a/tests/fixtures/contracts/warpline/legis-governance-read.golden.json b/tests/fixtures/contracts/warpline/legis-governance-read.golden.json new file mode 100644 index 0000000..898fd1a --- /dev/null +++ b/tests/fixtures/contracts/warpline/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/fixtures/contracts/warpline/mcp-response-changed.json b/tests/fixtures/contracts/warpline/mcp-response-changed.json index e53c9b3..0d25856 100644 --- a/tests/fixtures/contracts/warpline/mcp-response-changed.json +++ b/tests/fixtures/contracts/warpline/mcp-response-changed.json @@ -63,6 +63,16 @@ "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" + }, + "sei": { + "reason_class": "clean" + } + }, "meta": { "producer": {"tool": "warpline", "version": "0.1.0"}, "local_only": true, diff --git a/tests/fixtures/contracts/warpline/mcp-response-reverify.json b/tests/fixtures/contracts/warpline/mcp-response-reverify.json index dabfc65..25ffaeb 100644 --- a/tests/fixtures/contracts/warpline/mcp-response-reverify.json +++ b/tests/fixtures/contracts/warpline/mcp-response-reverify.json @@ -11,6 +11,24 @@ }, "data": { "completeness": "NO_SNAPSHOT", + "impact_completeness": { + "status": "unknown", + "as_of": "2026-01-01T00:00:00+00:00", + "graph_fresh": false, + "graph_ref": null, + "depth_capped": false, + "unresolved_count": 0, + "reasons": ["no_snapshot"] + }, + "risk_verification": { + "risk": "unavailable", + "reason_code": "completeness_partial", + "reason": { + "reason_class": "partial", + "cause": "impact completeness is 'unknown', not 'complete' (reasons: ['no_snapshot']); the impact set is narrowed/partial and is not authoritative", + "fix": "resolve the partial-coverage reasons (recapture a fresh graph, raise traversal depth, resolve unmapped entities) and regenerate the worklist" + } + }, "staleness": {"snapshot_commit": null, "commits_behind": null}, "resolved": [ { @@ -53,6 +71,13 @@ "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": "0.1.0"}, "local_only": true, diff --git a/tests/fixtures/contracts/warpline/mcp-tool-inventory.json b/tests/fixtures/contracts/warpline/mcp-tool-inventory.json index a9602ef..8ccc679 100644 --- a/tests/fixtures/contracts/warpline/mcp-tool-inventory.json +++ b/tests/fixtures/contracts/warpline/mcp-tool-inventory.json @@ -111,6 +111,23 @@ "peer_side_effects": [], "authority_boundary": "Returns per-entity change timeline; reports sei_resolution only, never lineage." }, + { + "name": "verify_record", + "endorsed_name": "warpline_verification_record", + "shim": "verify_record", + "schema": "warpline.verification_record.v1", + "mutates": true, + "read_only": false, + "writes_local_state": true, + "idempotent": true, + "mutates_paths": [ + ".weft/warpline/" + ], + "federation_dependencies": [], + "local_only": true, + "peer_side_effects": [], + "authority_boundary": "Records verification gate-pass events into .weft/warpline only; never mutates sibling repos. Advisory; warpline never gates." + }, { "name": "warpline_change_list", "endorsed_name": "warpline_change_list", @@ -218,6 +235,23 @@ "local_only": true, "peer_side_effects": [], "authority_boundary": "Returns reverify worklists; does not file work or govern changes." + }, + { + "name": "warpline_verification_record", + "endorsed_name": "warpline_verification_record", + "shim": "verify_record", + "schema": "warpline.verification_record.v1", + "mutates": true, + "read_only": false, + "writes_local_state": true, + "idempotent": true, + "mutates_paths": [ + ".weft/warpline/" + ], + "federation_dependencies": [], + "local_only": true, + "peer_side_effects": [], + "authority_boundary": "Records verification gate-pass events into .weft/warpline only; never mutates sibling repos. Advisory; warpline never gates." } ] } diff --git a/tests/test_anchor_capture.py b/tests/test_anchor_capture.py index 00c0066..828f082 100644 --- a/tests/test_anchor_capture.py +++ b/tests/test_anchor_capture.py @@ -163,7 +163,7 @@ def test_v1_db_opened_by_v2_client_migrates_and_old_rows_read_null( assert "detected_context" not in cols_before with WarplineStore.open(db) as store: - assert store.schema_version() == 3 + assert store.schema_version() == 4 row = store.conn.execute( "SELECT detected_branch, detected_head_sha, detected_at, detected_context " "FROM change_events WHERE commit_sha='deadbeef'" diff --git a/tests/test_attest.py b/tests/test_attest.py new file mode 100644 index 0000000..ceebc47 --- /dev/null +++ b/tests/test_attest.py @@ -0,0 +1,279 @@ +"""Risk-as-verification consumer of a wardline-attest-2 bundle (Rung 2). + +``_attest`` is pure: it maps (impact_completeness, affected SEIs, a PUSHED +attest-2 bundle, the current commit, an injected per-SEI content_hash) into a +risk-as-verification verdict. It closes D1's ``verification_source_absent`` gap — +a complete worklist whose every entity is attested clean at its current body +reads PROVEN-GOOD — while degrading to ``unavailable`` (never silent-clean) on +every honesty edge. The SEI + content_hash below are REAL loomweave values +(confirmed identical across loomweave's MCP surface and the HTTP identity +endpoint wardline's bundle-builder reads). +""" + +from __future__ import annotations + +from typing import Any + +from warpline._attest import ATTEST_RISK_CODES, ATTEST_SCHEMA, worklist_risk +from warpline._completeness import COMPLETENESS_RISK_CODES + +# Real loomweave values for warpline.reverify.render_reverify_worklist. +SEI_A = "loomweave:eid:9adc480cd5aa4d71503c19fd8b29907e" +HASH_A = "42f3670f4875735e840fe62a5efe2895a58ea2dd19e740b866e8be6af26208cc" +SEI_B = "loomweave:eid:00000000000000000000000000000bbb" +HASH_B = "bbbb670f4875735e840fe62a5efe2895a58ea2dd19e740b866e8be6af260bbbb" +COMMIT = "a5547265b157bf8bb6e9bc306fd611eb2ad8694d" + +_COMPLETE = {"status": "complete", "reasons": []} +_PARTIAL = {"status": "partial", "reasons": ["depth_capped"]} + + +def _boundary(sei: str, content_hash: str | None, verdict: str = "clean") -> dict[str, Any]: + return { + "qualname": "warpline.x", + "sei": sei, + "content_hash": content_hash, + "verdict": verdict, + "tier": "INTEGRAL", + } + + +def _bundle( + *, + boundaries: list[dict[str, Any]], + commit: str | None = COMMIT, + dirty: bool = False, + sei_source: str = "loomweave", + schema: str = ATTEST_SCHEMA, +) -> dict[str, Any]: + return { + "schema": schema, + "payload": { + "wardline_version": "1.0.7", + "attested_at": "2026-06-27", + "commit": commit, + "dirty": dirty, + "ruleset_hash": "sha256:deadbeef", + "posture": {}, + "boundaries": boundaries, + "sei_source": sei_source, + }, + "signature": {"alg": "HMAC-SHA256", "value": "deadbeef", "key_id": "04e04fc4"}, + } + + +def _hashes(mapping: dict[str, str]): + return lambda sei: mapping.get(sei) + + +def _risk(**kw): + return worklist_risk(**kw) + + +# ------------------------------------------------------------------- PROVEN-GOOD +def test_complete_worklist_all_attested_reads_proven_good() -> None: + verdict = _risk( + impact_completeness=_COMPLETE, + affected_seis=[SEI_A], + bundle=_bundle(boundaries=[_boundary(SEI_A, HASH_A)]), + current_commit=COMMIT, + content_hash_for_sei=_hashes({SEI_A: HASH_A}), + ) + assert verdict["risk"] == "proven" + assert verdict["reason_code"] == "attested_clean" + assert verdict["authority"] == "wardline" + assert verdict["source"] == ATTEST_SCHEMA + # the honest ceiling: warpline did NOT verify the HMAC; this is an echo. + assert verdict["signature_verified"] is False + assert verdict["matched"] == verdict["affected"] == 1 + # never a warpline-minted clean/allowed scalar + assert verdict["risk"] not in {"clean", "allowed"} + + +def test_proven_good_requires_every_affected_entity_all_or_nothing() -> None: + # SEI_A matches; SEI_B has no boundary -> the whole worklist is NOT proven. + verdict = _risk( + impact_completeness=_COMPLETE, + affected_seis=[SEI_A, SEI_B], + bundle=_bundle(boundaries=[_boundary(SEI_A, HASH_A)]), + current_commit=COMMIT, + content_hash_for_sei=_hashes({SEI_A: HASH_A, SEI_B: HASH_B}), + ) + assert verdict["risk"] == "unavailable" + assert verdict["reason_code"] == "attestation_incomplete" + + +# ------------------------------------------------------------------- completeness gate first +def test_partial_completeness_can_never_be_proven_even_with_bundle() -> None: + verdict = _risk( + impact_completeness=_PARTIAL, + affected_seis=[SEI_A], + bundle=_bundle(boundaries=[_boundary(SEI_A, HASH_A)]), + current_commit=COMMIT, + content_hash_for_sei=_hashes({SEI_A: HASH_A}), + ) + assert verdict["risk"] == "unavailable" + assert verdict["reason_code"] == "completeness_partial" + + +def test_absent_completeness_is_not_declared() -> None: + verdict = _risk(impact_completeness=None, affected_seis=[], bundle=None) + assert verdict["risk"] == "unavailable" + assert verdict["reason_code"] == "completeness_not_declared" + + +# ------------------------------------------------------------------- the Rung-2 gap (no bundle) +def test_complete_without_bundle_is_verification_source_absent() -> None: + verdict = _risk(impact_completeness=_COMPLETE, affected_seis=[SEI_A], bundle=None) + assert verdict["risk"] == "unavailable" + assert verdict["reason_code"] == "verification_source_absent" + + +# ------------------------------------------------------------------- honesty edges +def test_unknown_schema_is_rejected() -> None: + verdict = _risk( + impact_completeness=_COMPLETE, + affected_seis=[SEI_A], + bundle=_bundle(boundaries=[_boundary(SEI_A, HASH_A)], schema="wardline-attest-1"), + current_commit=COMMIT, + content_hash_for_sei=_hashes({SEI_A: HASH_A}), + ) + assert verdict["reason_code"] == "attestation_schema_unknown" + + +def test_dirty_tree_attestation_refused() -> None: + verdict = _risk( + impact_completeness=_COMPLETE, + affected_seis=[SEI_A], + bundle=_bundle(boundaries=[_boundary(SEI_A, HASH_A)], dirty=True), + current_commit=COMMIT, + content_hash_for_sei=_hashes({SEI_A: HASH_A}), + ) + assert verdict["reason_code"] == "attestation_dirty" + + +def test_null_commit_cannot_pin() -> None: + verdict = _risk( + impact_completeness=_COMPLETE, + affected_seis=[SEI_A], + bundle=_bundle(boundaries=[_boundary(SEI_A, HASH_A)], commit=None), + current_commit=COMMIT, + content_hash_for_sei=_hashes({SEI_A: HASH_A}), + ) + assert verdict["reason_code"] == "attestation_no_commit" + + +def test_commit_mismatch_blocks_proven() -> None: + verdict = _risk( + impact_completeness=_COMPLETE, + affected_seis=[SEI_A], + bundle=_bundle(boundaries=[_boundary(SEI_A, HASH_A)], commit="0" * 40), + current_commit=COMMIT, + content_hash_for_sei=_hashes({SEI_A: HASH_A}), + ) + assert verdict["reason_code"] == "attestation_commit_mismatch" + + +def test_sei_source_unavailable_is_unkeyed() -> None: + verdict = _risk( + impact_completeness=_COMPLETE, + affected_seis=[SEI_A], + bundle=_bundle(boundaries=[], sei_source="unavailable"), + current_commit=COMMIT, + content_hash_for_sei=_hashes({SEI_A: HASH_A}), + ) + assert verdict["reason_code"] == "attestation_unkeyed" + + +def test_empty_affected_set_with_valid_bundle_is_unkeyed_not_proven() -> None: + # A complete worklist with NO SEI-keyed affected entity (e.g. an install / + # backfill run while loomweave was unavailable) must NOT vacuously read + # proven on the empty all-or-nothing loop. A syntactically valid, SEI-keyed + # bundle is supplied, yet the worklist itself keys nothing → unavailable. + verdict = _risk( + impact_completeness=_COMPLETE, + affected_seis=[], + bundle=_bundle(boundaries=[_boundary(SEI_A, HASH_A)]), + current_commit=COMMIT, + content_hash_for_sei=_hashes({SEI_A: HASH_A}), + ) + assert verdict["risk"] == "unavailable" + assert verdict["reason_code"] == "attestation_unkeyed" + # never the vacuous proven-good with matched/affected 0 + assert verdict["risk"] not in {"proven", "clean", "allowed"} + + +def test_content_hash_drift_is_not_proven() -> None: + # boundary attests an OLD hash; the entity's current body moved -> not proven. + verdict = _risk( + impact_completeness=_COMPLETE, + affected_seis=[SEI_A], + bundle=_bundle(boundaries=[_boundary(SEI_A, "old" + HASH_A[3:])]), + current_commit=COMMIT, + content_hash_for_sei=_hashes({SEI_A: HASH_A}), + ) + assert verdict["reason_code"] == "attestation_incomplete" + + +def test_non_clean_verdict_is_not_proven() -> None: + for bad in ("defect", "unknown"): + verdict = _risk( + impact_completeness=_COMPLETE, + affected_seis=[SEI_A], + bundle=_bundle(boundaries=[_boundary(SEI_A, HASH_A, verdict=bad)]), + current_commit=COMMIT, + content_hash_for_sei=_hashes({SEI_A: HASH_A}), + ) + assert verdict["reason_code"] == "attestation_incomplete" + + +def test_null_content_hash_boundary_is_not_proven() -> None: + verdict = _risk( + impact_completeness=_COMPLETE, + affected_seis=[SEI_A], + bundle=_bundle(boundaries=[_boundary(SEI_A, None)]), + current_commit=COMMIT, + content_hash_for_sei=_hashes({SEI_A: HASH_A}), + ) + assert verdict["reason_code"] == "attestation_incomplete" + + +def test_unresolvable_current_hash_is_not_proven() -> None: + # loomweave could not give warpline the current content_hash -> cannot match. + verdict = _risk( + impact_completeness=_COMPLETE, + affected_seis=[SEI_A], + bundle=_bundle(boundaries=[_boundary(SEI_A, HASH_A)]), + current_commit=COMMIT, + content_hash_for_sei=_hashes({}), # returns None for SEI_A + ) + assert verdict["reason_code"] == "attestation_incomplete" + + +def test_structurally_unusable_bundle_is_schema_unknown() -> None: + verdict = _risk( + impact_completeness=_COMPLETE, + affected_seis=[SEI_A], + bundle="not-a-bundle", + current_commit=COMMIT, + content_hash_for_sei=_hashes({SEI_A: HASH_A}), + ) + assert verdict["reason_code"] == "attestation_schema_unknown" + + +def test_all_reason_codes_are_in_the_closed_vocab() -> None: + # exercise every branch once and assert the code is declared. + seen = set() + cases = [ + dict(impact_completeness=None, affected_seis=[], bundle=None), + dict(impact_completeness=_PARTIAL, affected_seis=[], bundle=None), + dict(impact_completeness=_COMPLETE, affected_seis=[SEI_A], bundle=None), + dict(impact_completeness=_COMPLETE, affected_seis=[SEI_A], + bundle=_bundle(boundaries=[_boundary(SEI_A, HASH_A)]), current_commit=COMMIT, + content_hash_for_sei=_hashes({SEI_A: HASH_A})), + ] + for c in cases: + seen.add(_risk(**c)["reason_code"]) + # worklist_risk's verdicts draw from its own attest vocab PLUS the completeness + # codes it delegates to completeness_risk for a non-complete worklist. + assert seen <= (ATTEST_RISK_CODES | COMPLETENESS_RISK_CODES) diff --git a/tests/test_commands.py b/tests/test_commands.py index c13005f..58e092a 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -75,6 +75,65 @@ def test_cli_capture_snapshot_degrades_without_loomweave( assert payload["meta"]["peer_side_effects"] == [] +def test_cli_capture_snapshot_preserves_prior_when_loomweave_absent( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """When loomweave is unavailable at re-capture but a usable prior FULL + snapshot already describes this commit, the capture must preserve it (not + downgrade to SKIPPED) and surface a PRESERVED warning. The stored graph and + its edges survive; read tools keep seeing FULL.""" + repo = tmp_path / "repo" + repo.mkdir() + with WarplineStore.open(default_store_path(repo)) as store: + repo_id = store.ensure_repo(repo) + a = store.ensure_entity_key( + repo_id, locator="python:function:a", sei=None, commit_sha="c1" + ) + b = store.ensure_entity_key( + repo_id, locator="python:function:b", sei=None, commit_sha="c1" + ) + prior_id = store.create_edge_snapshot(repo_id, "c1", "loomweave", "v1", "FULL") + store.append_snapshot_edge( + prior_id, + source_entity_key_id=a, + target_entity_key_id=b, + edge_kind="calls", + confidence="resolved", + ) + + assert ( + cli.main( + [ + "capture-snapshot", + "--repo", + str(repo), + "--commit", + "c1", + "--loomweave-command", + "/no/such/loomweave", + "--json", + ] + ) + == 0 + ) + payload = json.loads(capsys.readouterr().out) + assert payload["data"]["completeness"] == "FULL" + assert payload["data"]["edges"] == 0 + assert payload["data"]["snapshot_id"] == prior_id + assert any(w.startswith("PRESERVED:") for w in payload["warnings"]) + # enrichment honesty: the graph is real (present), the SEI peer was down. + assert payload["enrichment"]["edges"] == "present" + assert payload["enrichment"]["sei"] == "unavailable" + + # The stored FULL snapshot and its edge are untouched. + with WarplineStore.open(default_store_path(repo)) as store: + after = store.latest_snapshot(repo) + assert after is not None + assert int(after["id"]) == prior_id + assert after["completeness"] == "FULL" + assert len(store.snapshot_edges(prior_id)) == 1 + + def test_cli_backfill_with_resolve_sei_degrades_without_loomweave( tmp_path: Path, capsys: pytest.CaptureFixture[str] ) -> None: diff --git a/tests/test_completeness.py b/tests/test_completeness.py new file mode 100644 index 0000000..fb53a5e --- /dev/null +++ b/tests/test_completeness.py @@ -0,0 +1,197 @@ +"""Unit tests for the impact-completeness self-assessment (federation D1). + +``_completeness`` is pure (no store, no git, no I/O): it maps the already-computed +snapshot completeness + staleness + unresolved miss-set + depth-cap flag into the +federation-facing ``impact_completeness`` object, and provides the consumer-side +risk gate (risk-as-verification). Mirrors ``_enrichment`` / ``verification`` in +posture: it can only narrow toward honesty, never claim clean. +""" + +from __future__ import annotations + +from warpline._completeness import ( + COMPLETENESS_REASON_CODES, + COMPLETENESS_RISK_CODES, + IMPACT_COMPLETENESS_STATUS, + completeness_risk, + compute_impact_completeness, +) +from warpline.listing import REASON_CLASSES + +_FRESH = {"snapshot_commit": "c0ffee00", "commits_behind": 0} +_STALE = {"snapshot_commit": "c0ffee00", "commits_behind": 3} +_UNKNOWN_FRESH = {"snapshot_commit": "c0ffee00", "commits_behind": None} +_NO_SNAP = {"snapshot_commit": None, "commits_behind": None} +_AS_OF = "2026-01-01T00:00:00+00:00" + + +def _ic(**kwargs: object) -> dict: + """compute_impact_completeness with a pinned as_of (the producer timestamp is + a passthrough; these tests exercise the derived assessment).""" + kwargs.setdefault("as_of", _AS_OF) + return compute_impact_completeness(**kwargs) # type: ignore[arg-type] + + +def _assert_well_formed(obj: dict) -> None: + assert obj["status"] in IMPACT_COMPLETENESS_STATUS + assert obj["as_of"] == _AS_OF + assert isinstance(obj["graph_fresh"], bool) + assert obj["graph_ref"] is None or isinstance(obj["graph_ref"], str) + assert isinstance(obj["depth_capped"], bool) + assert isinstance(obj["unresolved_count"], int) + assert isinstance(obj["reasons"], list) + assert set(obj["reasons"]) <= COMPLETENESS_REASON_CODES + # the closed contract key set — wardline mirrors these verbatim. Both axes: + # staleness (as_of/graph_fresh/graph_ref) + completeness (status/depth_capped/ + # unresolved_count) + the machine reasons. + assert set(obj) == { + "status", + "as_of", + "graph_fresh", + "graph_ref", + "depth_capped", + "unresolved_count", + "reasons", + } + + +# ----------------------------------------------------------------- producer + + +def test_full_fresh_unresolved_zero_no_cap_is_complete() -> None: + obj = _ic( + completeness="FULL", staleness=_FRESH, unresolved=[], depth_capped=False + ) + _assert_well_formed(obj) + assert obj["status"] == "complete" + assert obj["graph_fresh"] is True + assert obj["graph_ref"] == "c0ffee00" + assert obj["depth_capped"] is False + assert obj["unresolved_count"] == 0 + assert obj["reasons"] == [] + + +def test_stale_graph_is_partial_never_complete() -> None: + obj = _ic( + completeness="FULL", staleness=_STALE, unresolved=[], depth_capped=False + ) + _assert_well_formed(obj) + assert obj["status"] == "partial" + assert obj["graph_fresh"] is False + assert "graph_stale" in obj["reasons"] + + +def test_unknown_freshness_is_not_complete() -> None: + # git could not compute commits_behind -> we cannot positively claim fresh, + # so we must NOT claim complete (conservative honesty). + obj = _ic( + completeness="FULL", staleness=_UNKNOWN_FRESH, unresolved=[], depth_capped=False + ) + _assert_well_formed(obj) + assert obj["status"] != "complete" + assert obj["graph_fresh"] is False + assert "graph_freshness_unknown" in obj["reasons"] + + +def test_depth_capped_is_partial() -> None: + obj = _ic( + completeness="FULL", staleness=_FRESH, unresolved=[], depth_capped=True + ) + _assert_well_formed(obj) + assert obj["status"] == "partial" + assert obj["depth_capped"] is True + assert "depth_capped" in obj["reasons"] + + +def test_unresolved_entities_make_partial() -> None: + obj = _ic( + completeness="FULL", + staleness=_FRESH, + unresolved=[{"ref": {"kind": "sei", "value": "x"}, "reason": "sei_not_in_snapshot"}], + depth_capped=False, + ) + _assert_well_formed(obj) + assert obj["status"] == "partial" + assert obj["unresolved_count"] == 1 + assert "unresolved_entities" in obj["reasons"] + + +def test_delta_snapshot_is_partial_even_when_fresh() -> None: + obj = _ic( + completeness="DELTA", staleness=_FRESH, unresolved=[], depth_capped=False + ) + _assert_well_formed(obj) + assert obj["status"] == "partial" + assert "partial_snapshot" in obj["reasons"] + + +def test_no_snapshot_is_unknown_not_complete() -> None: + obj = _ic( + completeness="NO_SNAPSHOT", staleness=_NO_SNAP, unresolved=[], depth_capped=False + ) + _assert_well_formed(obj) + assert obj["status"] == "unknown" + assert obj["graph_fresh"] is False + assert obj["graph_ref"] is None + assert "no_snapshot" in obj["reasons"] + + +def test_skipped_snapshot_is_unknown() -> None: + obj = _ic( + completeness="SKIPPED", staleness=_NO_SNAP, unresolved=[], depth_capped=False + ) + _assert_well_formed(obj) + assert obj["status"] == "unknown" + assert "snapshot_skipped" in obj["reasons"] + + +def test_invariant_never_complete_without_fresh_graph() -> None: + # The single hard invariant: complete REQUIRES a positively-fresh full graph. + for completeness in ("FULL", "DELTA", "NO_SNAPSHOT", "SKIPPED"): + for staleness in (_STALE, _UNKNOWN_FRESH, _NO_SNAP): + obj = _ic( + completeness=completeness, + staleness=staleness, + unresolved=[], + depth_capped=False, + ) + assert obj["status"] != "complete" + + +# ----------------------------------------------------------------- consumer gate + + +def _assert_risk_verdict(verdict: dict) -> None: + assert verdict["risk"] == "unavailable" # warpline NEVER declares clean + assert verdict["reason_code"] in COMPLETENESS_RISK_CODES + carrier = verdict["reason"] + assert carrier["reason_class"] in REASON_CLASSES + assert carrier["reason_class"] != "clean" + assert carrier.get("cause") and carrier.get("fix") + + +def test_consumer_absent_completeness_is_not_declared() -> None: + for absent in (None, {}, {"graph_fresh": True}): # missing 'status' + verdict = completeness_risk(absent) + _assert_risk_verdict(verdict) + assert verdict["reason_code"] == "completeness_not_declared" + + +def test_consumer_partial_degrades_to_unavailable() -> None: + verdict = completeness_risk({"status": "partial", "reasons": ["graph_stale"]}) + _assert_risk_verdict(verdict) + assert verdict["reason_code"] == "completeness_partial" + + +def test_consumer_unknown_also_degrades() -> None: + verdict = completeness_risk({"status": "unknown", "reasons": ["no_snapshot"]}) + _assert_risk_verdict(verdict) + assert verdict["reason_code"] == "completeness_partial" + + +def test_consumer_complete_is_still_never_clean() -> None: + # Even a complete impact set is not, by itself, a proven-good verdict: the + # risk-as-verification source (wardline attest bundle) is out of scope (gap). + verdict = completeness_risk({"status": "complete", "reasons": []}) + _assert_risk_verdict(verdict) + assert verdict["reason_code"] == "verification_source_absent" diff --git a/tests/test_consumer_ticket_package.py b/tests/test_consumer_ticket_package.py index 6137d8e..7b07e7c 100644 --- a/tests/test_consumer_ticket_package.py +++ b/tests/test_consumer_ticket_package.py @@ -2,7 +2,7 @@ from pathlib import Path -CONSUMERS = ["Loomweave", "Charter", "Legis", "Wardline", "Filigree"] +CONSUMERS = ["Loomweave", "Plainweave", "Legis", "Wardline", "Filigree"] def test_consumer_ticket_package_exists_for_every_pairwise_story() -> None: @@ -20,7 +20,7 @@ def test_consumer_ticket_package_keeps_authorities_separate() -> None: encoding="utf-8" ) assert "Loomweave owns current structure" in text - assert "Charter owns obligations" in text + assert "Plainweave owns obligations" in text assert "Legis owns governance" in text assert "Wardline owns trust policy" in text assert "Filigree owns work state" in text diff --git a/tests/test_enrichment_helpers.py b/tests/test_enrichment_helpers.py index 18184ec..3c2e85f 100644 --- a/tests/test_enrichment_helpers.py +++ b/tests/test_enrichment_helpers.py @@ -10,6 +10,8 @@ from pathlib import Path +import pytest + from warpline import commands from warpline._enrichment import ( EDGES_FOR_COMPLETENESS, @@ -174,8 +176,9 @@ def test_sei_unavailable_is_unreachable_with_cause_and_fix() -> None: assert triple["fix"] -def test_sei_unknown_state_yields_no_triple() -> None: - assert sei_reason("bogus") is None +def test_sei_unknown_state_raises_value_error() -> None: + with pytest.raises(ValueError): + sei_reason("bogus") def test_requirements_reason_is_stable_disabled() -> None: diff --git a/tests/test_envelope_reasons.py b/tests/test_envelope_reasons.py index 44cc0a8..335c8df 100644 --- a/tests/test_envelope_reasons.py +++ b/tests/test_envelope_reasons.py @@ -53,3 +53,8 @@ def test_envelope_rejects_reason_value_without_reason_class() -> None: def test_clean_reason_needs_no_cause_or_fix() -> None: env = _minimal_env(enrichment_reasons={"sei": reason("clean")}) assert env["enrichment_reasons"]["sei"] == {"reason_class": "clean"} + + +def test_envelope_rejects_hollow_non_clean_triple() -> None: + with pytest.raises(ValueError): + _minimal_env(enrichment_reasons={"sei": {"reason_class": "disabled"}}) diff --git a/tests/test_federation_consult.py b/tests/test_federation_consult.py index ab9ca71..351229c 100644 --- a/tests/test_federation_consult.py +++ b/tests/test_federation_consult.py @@ -133,6 +133,153 @@ def test_transport_blockers_name_the_missing_members() -> None: ) members = {b["member"] for b in blockers} assert members == set(FEDERATION_MEMBERS) - # legis blocker names the precise gap (no per-entity CLI read). + # legis blocker still names governance (the advisor pinned this), now recruiting + # an install/upgrade rather than a not-yet-built transport. legis = next(b for b in blockers if b["member"] == "legis") assert "governance" in legis["need"] + + +# --------------------------------------------------------------------------- legis +_CLEARED = { + "sei": "loomweave:eid:x", + "disposition": "cleared", + "posture": "protected_override", + "authority": "operator", + "as_of": "2026-06-27T14:02:11Z", + "reasons": ["operator_override"], + "content_hash": "b3:9f2ce7", +} + + +class _FakeLegis: + """A LegisClient stand-in: ``governance_for_sei`` returns canned records.""" + + def __init__(self, records: list[dict[str, Any]]) -> None: + self._records = records + + def governance_for_sei(self, sei: str) -> list[dict[str, Any]]: + return self._records + + +def test_legis_with_clearances_is_clean_and_carries_records() -> None: + items = [{"entity": {"locator": "python:function:a.py::a", "sei": "loomweave:eid:x"}}] + fed = consult_federation(items, legis_client=_FakeLegis([_CLEARED])) + assert fed["members"]["legis"]["weft_reason"]["reason_class"] == "clean" + entity = next(e for e in fed["entities"] if e["locator"] == "python:function:a.py::a") + assert entity["governance"] and entity["governance"][0]["disposition"] == "cleared" + + +def test_legis_reachable_but_empty_is_clean_earned_empty() -> None: + items = [{"entity": {"locator": "python:function:a.py::a", "sei": "loomweave:eid:x"}}] + fed = consult_federation(items, legis_client=_FakeLegis([])) + # no verified clearance is an EARNED empty (clean), NOT disabled/unreachable — + # and NOT a claim of "ungoverned". + assert fed["members"]["legis"]["weft_reason"]["reason_class"] == "clean" + assert fed["members"]["legis"]["entity_count"] == 0 + + +def test_legis_raising_is_unreachable_not_empty() -> None: + items = [{"entity": {"locator": "python:function:a.py::a", "sei": "loomweave:eid:x"}}] + + class Boom: + def governance_for_sei(self, sei: str) -> list[dict[str, Any]]: + raise RuntimeError("legis governance read exploded") + + fed = consult_federation(items, legis_client=Boom()) + wr = fed["members"]["legis"]["weft_reason"] + assert wr["reason_class"] == "unreachable" + assert "exploded" in wr["cause"] and wr["fix"] + + +def test_disabled_legis_fix_recruits_install_not_build(tmp_path: Path) -> None: + # Once the LegisGovernanceClient EXISTS, the disabled fix can no longer say + # "wire a LegisClient" (that work is done) — it must recruit installing/upgrading + # legis to a version that exposes the governance-read surface. + repo, key = _seed_repo_with_entity(tmp_path, sei="loomweave:eid:x") + env = commands.reverify_worklist(repo, [key], depth=2, include_federation=True) + wr = env["data"]["federation"]["members"]["legis"]["weft_reason"] + assert wr["reason_class"] == "disabled" + assert "governance-read" in wr["fix"] + assert "wire a LegisClient" not in wr["fix"] + + +# --------------------------------------------------------------------------- (acceptance 1) +def test_reverify_through_command_lights_governance_present(tmp_path: Path) -> None: + repo, key = _seed_repo_with_entity(tmp_path, sei="loomweave:eid:x") + env = commands.reverify_worklist( + repo, [key], depth=2, include_federation=True, legis_client=_FakeLegis([_CLEARED]) + ) + assert env["data"]["federation"]["members"]["legis"]["weft_reason"]["reason_class"] == "clean" + assert env["enrichment"]["governance"] == "present" + item = env["data"]["items"][0] + assert item["enrichment"]["governance"][0]["disposition"] == "cleared" + + +# --------------------------------------------------------------------------- (acceptance 3) +def _find_key(node: Any, key: str) -> bool: + if isinstance(node, dict): + return key in node or any(_find_key(v, key) for v in node.values()) + if isinstance(node, list): + return any(_find_key(v, key) for v in node) + return False + + +def test_governance_is_advisory_never_gates_the_decision(tmp_path: Path) -> None: + """GV-LG-1: the legis echo MUST NOT move the reverify decision. Same repo, two + runs differing ONLY in the legis facts (a clearance vs none); the decision + substrate — verification_summary, risk_verification, impact_completeness, + item identity/order, resolved/unresolved — is byte-identical, and no + ``governance_verdict`` leaks anywhere in the envelope.""" + + repo, key = _seed_repo_with_entity(tmp_path, sei="loomweave:eid:x") + with_clearance = commands.reverify_worklist( + repo, [key], depth=2, include_federation=True, legis_client=_FakeLegis([_CLEARED]) + ) + without = commands.reverify_worklist( + repo, [key], depth=2, include_federation=True, legis_client=_FakeLegis([]) + ) + # the advisory scalar DID flip (the signal is real)... + assert with_clearance["enrichment"]["governance"] == "present" + assert without["enrichment"]["governance"] == "absent" + # ...but NOTHING in the decision substrate moved (as_of is a per-call wall-clock + # producer timestamp, inherently different between two runs — strip it). + def _decision(env: dict[str, Any]) -> dict[str, Any]: + ic = {k: v for k, v in env["data"]["impact_completeness"].items() if k != "as_of"} + return { + "verification_summary": env["data"]["verification_summary"], + "risk_verification": env["data"]["risk_verification"], + "impact_completeness": ic, + "resolved": env["data"]["resolved"], + "unresolved": env["data"]["unresolved"], + } + + assert _decision(with_clearance) == _decision(without) + assert [i["entity"]["locator"] for i in with_clearance["data"]["items"]] == [ + i["entity"]["locator"] for i in without["data"]["items"] + ] + # governance never masquerades as a verdict. + assert not _find_key(with_clearance, "governance_verdict") + # and it never leaks into the verification posture. + assert not _find_key(with_clearance["data"]["risk_verification"], "governance") + assert not _find_key(with_clearance["data"]["impact_completeness"], "governance") + + +def test_h_reverify_capability_gated_wiring_lights_governance(tmp_path: Path, monkeypatch) -> None: + """The MCP handler's capability-gated construction, end-to-end: when legis + advertises the verb and returns a clearance, ``_h_reverify`` emits + ``governance: present`` (filigree/wardline have no transport here and degrade + independently — governance is unaffected).""" + + from warpline import mcp + from warpline.federation import LegisGovernanceClient + + repo, key = _seed_repo_with_entity(tmp_path, sei="loomweave:eid:x") + monkeypatch.setattr(LegisGovernanceClient, "available", classmethod(lambda cls, repo: True)) + monkeypatch.setattr( + LegisGovernanceClient, "governance_for_sei", lambda self, sei: [_CLEARED] + ) + env = mcp._h_reverify( + {"repo": str(repo), "changed_entity_key_ids": [key], "depth": 2, "include_federation": True} + ) + assert env["enrichment"]["governance"] == "present" + assert env["data"]["federation"]["members"]["legis"]["weft_reason"]["reason_class"] == "clean" diff --git a/tests/test_git_reachability.py b/tests/test_git_reachability.py new file mode 100644 index 0000000..0d40feb --- /dev/null +++ b/tests/test_git_reachability.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +import subprocess +from pathlib import Path + +from warpline.git import commits_between, is_ancestor, resolve_commit + + +def _run(repo: Path, *args: str) -> str: + return subprocess.run( + ["git", *args], cwd=repo, check=True, text=True, capture_output=True + ).stdout.strip() + + +def _repo_with_three_commits(tmp_path: Path) -> tuple[Path, list[str]]: + repo = tmp_path / "r" + repo.mkdir() + _run(repo, "init", "-q") + _run(repo, "config", "user.email", "t@t") + _run(repo, "config", "user.name", "t") + shas: list[str] = [] + for i in range(3): + (repo / "f.txt").write_text(f"v{i}\n") + _run(repo, "add", ".") + _run(repo, "commit", "-q", "-m", f"c{i}") + shas.append(_run(repo, "rev-parse", "HEAD")) + return repo, shas + + +def test_resolve_commit_resolves_head_to_object_sha(tmp_path: Path) -> None: + repo, shas = _repo_with_three_commits(tmp_path) + resolved = resolve_commit(repo, "HEAD") + assert resolved == shas[2] + assert len(resolved) == 40 + + +def test_resolve_commit_returns_none_for_bad_ref(tmp_path: Path) -> None: + repo, _ = _repo_with_three_commits(tmp_path) + assert resolve_commit(repo, "no-such-ref") is None + + +def test_is_ancestor_true_for_earlier_commit(tmp_path: Path) -> None: + repo, shas = _repo_with_three_commits(tmp_path) + assert is_ancestor(repo, shas[0], shas[2]) is True + + +def test_is_ancestor_true_for_equal_commit(tmp_path: Path) -> None: + repo, shas = _repo_with_three_commits(tmp_path) + assert is_ancestor(repo, shas[1], shas[1]) is True + + +def test_is_ancestor_false_for_later_commit(tmp_path: Path) -> None: + repo, shas = _repo_with_three_commits(tmp_path) + assert is_ancestor(repo, shas[2], shas[0]) is False + + +def test_is_ancestor_none_for_unknown_commit(tmp_path: Path) -> None: + repo, shas = _repo_with_three_commits(tmp_path) + assert is_ancestor(repo, "f" * 40, shas[0]) is None + + +def test_commits_between_counts_distance(tmp_path: Path) -> None: + repo, shas = _repo_with_three_commits(tmp_path) + assert commits_between(repo, shas[0], shas[2]) == 2 + + +def test_commits_between_zero_for_same(tmp_path: Path) -> None: + repo, shas = _repo_with_three_commits(tmp_path) + assert commits_between(repo, shas[1], shas[1]) == 0 + + +def test_commits_between_none_for_unknown(tmp_path: Path) -> None: + repo, shas = _repo_with_three_commits(tmp_path) + assert commits_between(repo, "f" * 40, shas[0]) is None diff --git a/tests/test_honesty_invariant.py b/tests/test_honesty_invariant.py index 096c443..c7dbf20 100644 --- a/tests/test_honesty_invariant.py +++ b/tests/test_honesty_invariant.py @@ -396,6 +396,41 @@ def neighborhood(self, entity: str) -> dict[str, object]: assert "python:function:drop" not in seen +def test_capture_at_new_commit_reports_created_not_already_current(tmp_path: Path) -> None: + """A capture that writes a snapshot for a NEW commit reports ``created`` even + though a prior snapshot already exists for the repo. The idempotency signal + must reflect whether THIS capture was skipped/reused, not merely whether any + snapshot existed — otherwise a genuinely new row is mislabelled + ``already_current`` and a caller skips real follow-up work.""" + + repo = _init_repo(tmp_path) + first = _commit(repo, "a.py", "a = 1\n") + env1 = commands.capture_snapshot(repo, commit=first) + assert env1["data"]["idempotency"] == "created" + + second = _commit(repo, "a.py", "a = 2\n") + env2 = commands.capture_snapshot(repo, commit=second) + # a prior snapshot (at `first`) exists, but this wrote a NEW row for `second` + assert env2["data"]["idempotency"] == "created" + + +def test_recapture_with_usable_prior_reports_already_current(tmp_path: Path) -> None: + """The reuse branch is locked too: a loomweave-absent recapture that preserves + a usable FULL/DELTA prior for the SAME commit reports ``already_current`` — + the only honest ``already_current``, because no new row was written.""" + + repo = _init_repo(tmp_path) + head = _commit(repo, "a.py", "a = 1\n") + with WarplineStore.open(default_store_path(repo)) as store: + repo_id = store.ensure_repo(repo) + # a real FULL prior at HEAD's commit; loomweave is absent on recapture, so + # capture_edge_snapshot preserves it (recapture_skipped) rather than write. + store.create_edge_snapshot(repo_id, head, "loomweave", "test", "FULL") + + env = commands.capture_snapshot(repo, commit=head) + assert env["data"]["idempotency"] == "already_current" + + def test_capture_if_stale_after_short_circuits(tmp_path: Path) -> None: """A current snapshot captured at-or-after the watermark skips recapture and reports already_current with a FRESH warning — the field is honored, not diff --git a/tests/test_legis_governance_client.py b/tests/test_legis_governance_client.py new file mode 100644 index 0000000..3864cff --- /dev/null +++ b/tests/test_legis_governance_client.py @@ -0,0 +1,164 @@ +"""LegisGovernanceClient — warpline's read-only consumer of legis governance_read.v1. + +The client invokes ``legis governance-read --json`` (mirroring +``WardlineDossierClient`` over ``wardline dossier``) and maps the +``governance_read.v1`` envelope onto the ``LegisClient`` Protocol: + + * status=checked -> return ``records`` ([] is an earned-empty, returned as-is) + * status=unavailable / nonzero exit / tampered / unparseable -> raise + ``LegisGovernanceUnavailable`` (so ``_consult_legis`` reports ``unreachable``, + never a confident-empty). + +It NEVER re-derives the clearance ``content_hash`` against the current body: +governance is an advisory ECHO of a legis fact, not a warpline-asserted verdict. +""" + +from __future__ import annotations + +import json +import subprocess +from pathlib import Path +from typing import Any + +import pytest + +from warpline.federation import LegisClient, LegisGovernanceClient, LegisGovernanceUnavailable + +_SEI = "loomweave:eid:7Q3fc1" +_CHECKED = { + "status": "checked", + "sei": _SEI, + "records": [ + { + "sei": _SEI, + "disposition": "cleared", + "posture": "protected_override", + "authority": "operator", + "as_of": "2026-06-27T14:02:11Z", + "reasons": ["operator_override"], + "content_hash": "b3:9f2ce7", + } + ], +} + + +class _FakeProc: + def __init__(self, stdout: str = "", returncode: int = 0) -> None: + self.stdout = stdout + self.stderr = "" + self.returncode = returncode + + +def _patch(monkeypatch, fake_run) -> None: + monkeypatch.setattr("warpline.federation.subprocess.run", fake_run) + + +def _read_run(payload: dict[str, Any]): + def fake_run(cmd, **kw): + return _FakeProc(stdout=json.dumps(payload)) + + return fake_run + + +# --- the Protocol is satisfied ------------------------------------------------ +def test_client_satisfies_legis_client_protocol() -> None: + client: LegisClient = LegisGovernanceClient(Path("/repo")) + assert callable(client.governance_for_sei) + + +def test_invokes_legis_governance_read_without_json_flag(monkeypatch) -> None: + # legis's shipped CLI is `legis governance-read ` with output ALWAYS JSON; + # a `--json` flag is an argparse error (nonzero exit). Pin the exact argv so the + # invocation form can never drift back out of sync with legis. + seen: dict[str, Any] = {} + + def fake_run(cmd, **kw): + seen["cmd"] = cmd + return _FakeProc(stdout=json.dumps(_CHECKED)) + + _patch(monkeypatch, fake_run) + LegisGovernanceClient(Path("/repo"), command="legis").governance_for_sei(_SEI) + assert seen["cmd"] == ["legis", "governance-read", _SEI] + assert "--json" not in seen["cmd"] + + +# --- status=checked ----------------------------------------------------------- +def test_checked_with_records_returns_them(monkeypatch) -> None: + _patch(monkeypatch, _read_run(_CHECKED)) + records = LegisGovernanceClient(Path("/repo")).governance_for_sei(_SEI) + assert len(records) == 1 + assert records[0]["disposition"] == "cleared" + assert records[0]["posture"] == "protected_override" + # content_hash is echoed verbatim, never re-derived. + assert records[0]["content_hash"] == "b3:9f2ce7" + + +def test_checked_empty_returns_empty_list(monkeypatch) -> None: + _patch(monkeypatch, _read_run({"status": "checked", "sei": _SEI, "records": []})) + # earned-empty: no verified clearance. Returned as [], NEVER raised. + assert LegisGovernanceClient(Path("/repo")).governance_for_sei(_SEI) == [] + + +def test_non_dict_records_are_filtered(monkeypatch) -> None: + _patch(monkeypatch, _read_run({"status": "checked", "sei": _SEI, "records": ["junk", {}]})) + out = LegisGovernanceClient(Path("/repo")).governance_for_sei(_SEI) + assert out == [{}] + + +# --- status=unavailable / failures -> raise ----------------------------------- +def test_unavailable_status_raises_with_reasons(monkeypatch) -> None: + payload = { + "status": "unavailable", + "sei": _SEI, + "records": [], + "unavailable": [{"reason": "trail not signature-verifiable"}], + } + _patch(monkeypatch, _read_run(payload)) + with pytest.raises(LegisGovernanceUnavailable) as exc: + LegisGovernanceClient(Path("/repo")).governance_for_sei(_SEI) + assert exc.value.sei == _SEI + assert "not signature-verifiable" in repr(exc.value.reasons) + + +def test_nonzero_exit_raises(monkeypatch) -> None: + def fake_run(cmd, **kw): + raise subprocess.CalledProcessError(returncode=2, cmd=cmd, stderr="tampered") + + _patch(monkeypatch, fake_run) + with pytest.raises(LegisGovernanceUnavailable): + LegisGovernanceClient(Path("/repo")).governance_for_sei(_SEI) + + +def test_unparseable_output_raises(monkeypatch) -> None: + _patch(monkeypatch, lambda *a, **k: _FakeProc(stdout="not json")) + with pytest.raises(LegisGovernanceUnavailable): + LegisGovernanceClient(Path("/repo")).governance_for_sei(_SEI) + + +def test_missing_binary_raises(monkeypatch) -> None: + def fake_run(cmd, **kw): + raise FileNotFoundError("legis") + + _patch(monkeypatch, fake_run) + with pytest.raises(LegisGovernanceUnavailable): + LegisGovernanceClient(Path("/repo")).governance_for_sei(_SEI) + + +# --- capability probe (gates the live wiring so an unshipped verb stays disabled) +def test_available_true_when_verb_advertised(monkeypatch) -> None: + help_text = "usage: legis {serve,governance-read,doctor}" + _patch(monkeypatch, lambda *a, **k: _FakeProc(stdout=help_text)) + assert LegisGovernanceClient.available(Path("/repo")) is True + + +def test_available_false_when_verb_absent(monkeypatch) -> None: + _patch(monkeypatch, lambda *a, **k: _FakeProc(stdout="usage: legis {serve,mcp,doctor}")) + assert LegisGovernanceClient.available(Path("/repo")) is False + + +def test_available_false_when_binary_missing(monkeypatch) -> None: + def fake_run(cmd, **kw): + raise FileNotFoundError("legis") + + _patch(monkeypatch, fake_run) + assert LegisGovernanceClient.available(Path("/repo")) is False diff --git a/tests/test_list_ergonomics.py b/tests/test_list_ergonomics.py index 085921f..83941a8 100644 --- a/tests/test_list_ergonomics.py +++ b/tests/test_list_ergonomics.py @@ -196,6 +196,19 @@ def test_cursor_past_end_is_honest_partial_not_silent_clean(tmp_path: Path) -> N assert "cause" in page and "fix" in page +def test_non_positive_limit_rejects_loudly(tmp_path: Path) -> None: + # A limit of 0 (or negative) would slice an empty window without advancing the + # offset, so a non-empty result reports has_more:true with a self-referential + # next_cursor — a cursor-following client loops forever. Reject it loudly, like + # every other non-positive numeric bound (max_entities, cursor offset). + repo = tmp_path / "repo" + repo.mkdir() + _three_changes(repo) + for bad in (0, -1, -50): + with pytest.raises(InvalidSortError, match="limit"): + commands.change_list(repo, limit=bad) + + def test_malformed_cursor_rejects_loudly(tmp_path: Path) -> None: repo = tmp_path / "repo" repo.mkdir() @@ -349,6 +362,34 @@ def test_include_next_actions_false_suppresses_next_actions(tmp_path: Path) -> N assert off["next_actions"] == {} +def test_change_list_filters_narrow_followup_seeds(tmp_path: Path) -> None: + # When a path_prefix filter narrows the visible items, the follow-up seeds + # (data.changed_refs + the reverify/blast next_action arguments) must narrow + # with them. Otherwise following the advertised next action rechecks unrelated + # unfiltered changes (here: lib/c.py, which the src/ filter excludes). + repo = tmp_path / "repo" + repo.mkdir() + _three_changes(repo) + env = commands.change_list(repo, filters={"path_prefix": "src/"}) + + # only the src/ entities are visible + assert {i["entity"]["path"] for i in env["data"]["items"]} == {"src/a.py", "src/b.py"} + + # the data.changed_refs seed drops the filtered-out lib/c.py entity + seed_values = {r["value"] for r in env["data"]["changed_refs"]} + assert seed_values == {"loomweave:eid:a", "python:function:b"} + assert "python:function:c" not in seed_values + + # and BOTH next_action seed lists (refs + key_ids) reflect the filtered set + for action in ("warpline_reverify_worklist_get", "warpline_impact_radius_get"): + args = env["next_actions"][action]["arguments"] + assert {r["value"] for r in args["changed_refs"]} == { + "loomweave:eid:a", + "python:function:b", + } + assert len(args["changed_entity_key_ids"]) == 2 + + def test_base_head_ref_conflicts_with_rev_range(tmp_path: Path) -> None: repo = tmp_path / "repo" repo.mkdir() @@ -450,7 +491,7 @@ def test_reason_clean_omits_cause_and_fix() -> None: def test_reason_nonclean_requires_cause_and_fix() -> None: carrier = reason("partial", cause="capped", fix="raise the cap") assert carrier == {"reason_class": "partial", "cause": "capped", "fix": "raise the cap"} - with pytest.raises(AssertionError): + with pytest.raises(ValueError): reason("partial") # missing cause/fix diff --git a/tests/test_mcp.py b/tests/test_mcp.py index 7d99fd4..864e605 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -64,7 +64,15 @@ def test_tools_list_contains_changed_and_timeline() -> None: assert metadata["peer_side_effects"] == [] assert isinstance(metadata["idempotent"], bool) assert isinstance(metadata["writes_local_state"], bool) - assert ".weft/warpline/" in metadata["mutates_paths"] + # A tool that writes local state must declare .weft/warpline as its mutate + # surface; a GENUINELY read-only tool (the project_status binding probe) + # initializes nothing and declares an empty mutate surface. The honest + # invariant is the either/or, not a blanket "everything writes". + if metadata["writes_local_state"]: + assert ".weft/warpline/" in metadata["mutates_paths"] + else: + assert metadata["read_only"] is True + assert metadata["mutates_paths"] == [] def test_endorsed_and_shim_return_identical_schema_and_data(tmp_path: Path) -> None: diff --git a/tests/test_project_status.py b/tests/test_project_status.py new file mode 100644 index 0000000..33434e4 --- /dev/null +++ b/tests/test_project_status.py @@ -0,0 +1,459 @@ +"""Read-only store-binding/status probe (``warpline_project_status_get``). + +The Lacuna MCP-attachment regression harness asserts every federation member is +not merely *attached* but *bound to and able to serve* the staged repo. warpline +is repo-per-call (bound to nothing at launch), so "bound" here means: called with +``repo=R``, this build can READ warpline's snapshot store for R at a schema it +serves. The load-bearing signal is the schema version read FROM INSIDE the store +(with an absent/error sentinel) — never mere directory existence, which a stale +binary that cannot read the store would still see. +""" + +from __future__ import annotations + +import hashlib +import sqlite3 +from pathlib import Path + +from warpline import commands +from warpline.mcp import dispatch +from warpline.store import ( + HIGHEST_KNOWN_VERSION, + STORE_STATUS_VOCAB, + WarplineStore, + default_store_path, + read_store_binding, + store_repo_id, +) + + +def _empty_store(repo: Path) -> None: + """A real, serveable store with NO change events and NO captured snapshot.""" + + with WarplineStore.open(default_store_path(repo)) as store: + store.ensure_repo(repo) + + +def _set_versions(repo: Path, *, meta: int | None = None, user_version: int | None = None) -> None: + """Tamper the on-disk schema markers independently (models a foreign writer).""" + + conn = sqlite3.connect(default_store_path(repo)) + if meta is not None: + conn.execute("UPDATE meta SET value = ? WHERE key = 'schema_version'", (str(meta),)) + if user_version is not None: + conn.execute(f"PRAGMA user_version = {user_version}") + conn.commit() + conn.close() + + +# --------------------------------------------------------------------------- helpers +def _populate_store(repo: Path) -> None: + """Build a REAL warpline store the production way (open → write → close). + + Closing the context manager triggers SQLite's last-connection WAL checkpoint, + so the discriminator test below reads a genuine WAL-mode store — not a + hand-built rollback-journal DB that would mask a read-only-open WAL bug. + """ + + with WarplineStore.open(default_store_path(repo)) as store: + repo_id = store.ensure_repo(repo) + key_id = store.ensure_entity_key(repo_id, "app.py::f", None, "c0ffee") + store.append_change_event( + repo_id=repo_id, + entity_key_id=key_id, + commit_sha="c0ffee", + path="app.py", + change_kind="modified", + actor="agent", + changed_at="2026-06-01T00:00:00+00:00", + ) + store.create_edge_snapshot(repo_id, "c0ffee", "loomweave", "0", "FULL") + + +def _call_status(repo: Path, name: str = "warpline_project_status_get") -> dict[str, object]: + response = dispatch( + { + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": {"name": name, "arguments": {"repo": str(repo)}}, + } + ) + result = response["result"] + assert isinstance(result, dict) + structured = result["structuredContent"] + assert isinstance(structured, dict) + return structured + + +# --------------------------------------------------------------------------- reader: absent +def test_read_store_binding_absent_reports_absent_and_creates_nothing(tmp_path: Path) -> None: + repo = tmp_path / "repo" + repo.mkdir() + + binding = read_store_binding(repo) + + assert binding.present is False + assert binding.readable is False + assert binding.schema_version is None + assert binding.snapshot_rev is None + assert binding.change_event_count is None + assert binding.binding_ok is False + assert binding.status == "store_absent" + # Read-only: an absent store stays absent — never initialized. + assert not (repo / ".weft" / "warpline").exists() + assert not default_store_path(repo).exists() + + +# --- reader: the WAL discriminator +def test_read_store_binding_present_readable_real_wal_store(tmp_path: Path) -> None: + """The load-bearing test: a real, closed (checkpointed) WAL store reads OK.""" + + repo = tmp_path / "repo" + repo.mkdir() + _populate_store(repo) + + binding = read_store_binding(repo) + + assert binding.present is True + assert binding.readable is True + assert binding.schema_version == HIGHEST_KNOWN_VERSION + assert binding.binding_ok is True + assert binding.change_event_count == 1 + assert binding.snapshot_rev == "c0ffee" + assert binding.status == "ok" + + +# --- reader: schema ahead (stale binary) +def test_read_store_binding_schema_ahead_is_unreadable(tmp_path: Path) -> None: + """A store written by a NEWER build (schema beyond this build) is the + stale-binary case: readable=False, schema_version=null, binding_ok=False.""" + + repo = tmp_path / "repo" + repo.mkdir() + _populate_store(repo) + ahead = HIGHEST_KNOWN_VERSION + 99 + conn = sqlite3.connect(default_store_path(repo)) + conn.execute("UPDATE meta SET value = ? WHERE key = 'schema_version'", (str(ahead),)) + conn.execute(f"PRAGMA user_version = {ahead}") + conn.commit() + conn.close() + + binding = read_store_binding(repo) + + assert binding.present is True + assert binding.readable is False + assert binding.schema_version is None + assert binding.binding_ok is False + assert binding.status == "schema_ahead" + assert str(ahead) in binding.detail + + +# --------------------------------------------------------------------------- reader: corrupt +def test_read_store_binding_corrupt_store_is_unreadable(tmp_path: Path) -> None: + repo = tmp_path / "repo" + repo.mkdir() + store_path = default_store_path(repo) + store_path.parent.mkdir(parents=True, exist_ok=True) + store_path.write_bytes(b"this is not a sqlite database") + + binding = read_store_binding(repo) + + assert binding.present is True + assert binding.readable is False + assert binding.schema_version is None + assert binding.binding_ok is False + assert binding.status == "store_unreadable" + + +# --- tools/list registration + honest metadata +def test_project_status_in_tools_list_with_read_only_metadata() -> None: + response = dispatch({"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {}}) + tools = response["result"]["tools"] + by_name = {tool["name"]: tool for tool in tools} + + assert "warpline_project_status_get" in by_name + assert "project_status" in by_name + meta = by_name["warpline_project_status_get"]["metadata"] + # The whole point: this tool is GENUINELY read-only — it writes/initializes + # nothing, unlike the lazy-init read tools that declare writes_local_state. + assert meta["read_only"] is True + assert meta["writes_local_state"] is False + assert meta["mutates_paths"] == [] + assert meta["local_only"] is True + assert meta["peer_side_effects"] == [] + + +# --------------------------------------------------------------------------- dispatch: bound +def test_project_status_binding_ok_over_mcp(tmp_path: Path) -> None: + repo = tmp_path / "repo" + repo.mkdir() + _populate_store(repo) + + payload = _call_status(repo) + + assert payload["schema"] == "warpline.project_status.v1" + assert payload["ok"] is True + data = payload["data"] + assert isinstance(data, dict) + assert data["resolved_root"] == str(repo.resolve()) + assert data["binding_ok"] is True + store = data["store"] + assert isinstance(store, dict) + assert store["present"] is True + assert store["readable"] is True + assert store["schema_version"] == HIGHEST_KNOWN_VERSION + # honesty meta rides on the envelope like every warpline payload + assert payload["meta"]["local_only"] is True + assert payload["meta"]["peer_side_effects"] == [] + + +# --- dispatch: absent → not bound + next action +def test_project_status_absent_store_over_mcp(tmp_path: Path) -> None: + repo = tmp_path / "repo" + repo.mkdir() + + payload = _call_status(repo) + + assert payload["ok"] is True # the CALL succeeded; the VERDICT is binding_ok + data = payload["data"] + assert isinstance(data, dict) + assert data["binding_ok"] is False + assert data["store"]["present"] is False + assert data["store"]["schema_version"] is None + # an absent store gets a ready-to-call capture hint + assert "warpline_edge_snapshot_capture" in payload["next_actions"] + + +# --------------------------------------------------------------------------- dispatch: schema ahead +def test_project_status_schema_ahead_over_mcp(tmp_path: Path) -> None: + repo = tmp_path / "repo" + repo.mkdir() + _populate_store(repo) + ahead = HIGHEST_KNOWN_VERSION + 99 + conn = sqlite3.connect(default_store_path(repo)) + conn.execute("UPDATE meta SET value = ? WHERE key = 'schema_version'", (str(ahead),)) + conn.execute(f"PRAGMA user_version = {ahead}") + conn.commit() + conn.close() + + payload = _call_status(repo) + data = payload["data"] + assert isinstance(data, dict) + assert data["binding_ok"] is False + assert data["store"]["readable"] is False + assert data["store"]["schema_version"] is None + + +# --- dispatch: read-only invariant +def test_project_status_over_mcp_initializes_nothing(tmp_path: Path) -> None: + repo = tmp_path / "repo" + repo.mkdir() + + _call_status(repo) + + # The full dispatch path must not mkdir / open / migrate the store. + assert not (repo / ".weft" / "warpline").exists() + assert not default_store_path(repo).exists() + + +# --------------------------------------------------------------------------- endorsed == shim +def test_project_status_endorsed_and_shim_identical(tmp_path: Path) -> None: + repo = tmp_path / "repo" + repo.mkdir() + _populate_store(repo) + + endorsed = _call_status(repo, "warpline_project_status_get") + shim = _call_status(repo, "project_status") + + assert endorsed["schema"] == shim["schema"] == "warpline.project_status.v1" + assert endorsed["data"] == shim["data"] + + +# --------------------------------------------------------------------------- command-layer direct +def test_command_project_status_envelope_shape(tmp_path: Path) -> None: + repo = tmp_path / "repo" + repo.mkdir() + + envelope = commands.project_status(repo) + + assert envelope["schema"] == "warpline.project_status.v1" + assert envelope["ok"] is True + assert set(envelope["data"]["store"]) == { + "present", + "readable", + "schema_version", + "snapshot_rev", + "change_event_count", + } + + +# --- D4: each arm of max(meta, user_version) must independently trip schema_ahead +def test_schema_ahead_when_only_user_version_is_bumped(tmp_path: Path) -> None: + repo = tmp_path / "repo" + repo.mkdir() + _populate_store(repo) + # meta stays at HIGHEST; only PRAGMA user_version moves ahead. + _set_versions(repo, user_version=HIGHEST_KNOWN_VERSION + 5) + + binding = read_store_binding(repo) + + assert binding.status == "schema_ahead" + assert binding.readable is False + assert binding.schema_version is None + assert binding.binding_ok is False + + +def test_schema_ahead_when_only_meta_is_bumped(tmp_path: Path) -> None: + repo = tmp_path / "repo" + repo.mkdir() + _populate_store(repo) + # user_version stays at HIGHEST; only the meta marker moves ahead. + _set_versions(repo, meta=HIGHEST_KNOWN_VERSION + 5) + + binding = read_store_binding(repo) + + assert binding.status == "schema_ahead" + assert binding.readable is False + assert binding.schema_version is None + assert binding.binding_ok is False + + +# --- D3: an empty-but-serveable store is bound (binding_ok independent of count/snapshot) +def test_empty_but_serveable_store_is_bound(tmp_path: Path) -> None: + repo = tmp_path / "repo" + repo.mkdir() + _empty_store(repo) + + binding = read_store_binding(repo) + + assert binding.present is True + assert binding.readable is True + assert binding.binding_ok is True + assert binding.status == "ok" + assert binding.schema_version == HIGHEST_KNOWN_VERSION + # binding_ok does NOT require any change events or a captured snapshot + assert binding.change_event_count == 0 + assert binding.snapshot_rev is None + + +# --- a store BELOW this build's HIGHEST schema is still serveable +def test_below_highest_schema_is_serveable(tmp_path: Path) -> None: + repo = tmp_path / "repo" + repo.mkdir() + _populate_store(repo) + # Model an older store this build still knows how to read (v2 < HIGHEST=4). + _set_versions(repo, meta=2, user_version=2) + + binding = read_store_binding(repo) + + assert binding.readable is True + assert binding.binding_ok is True + assert binding.status == "ok" + assert binding.schema_version == 2 + + +# --- read-only invariant on a PRESENT store: the durable DB is never mutated +def test_present_store_durable_db_unchanged_after_probe(tmp_path: Path) -> None: + repo = tmp_path / "repo" + repo.mkdir() + _populate_store(repo) + db = default_store_path(repo) + before = hashlib.sha256(db.read_bytes()).hexdigest() + + # full dispatch path, twice (idempotent, no durable writes) + _call_status(repo) + _call_status(repo) + + after = hashlib.sha256(db.read_bytes()).hexdigest() + assert after == before # no rows written; the snapshot store is untouched + + +# --- store dir exists but the DB file does not → absent +def test_dir_without_db_is_absent(tmp_path: Path) -> None: + repo = tmp_path / "repo" + repo.mkdir() + default_store_path(repo).parent.mkdir(parents=True, exist_ok=True) # .weft/warpline, no db + + binding = read_store_binding(repo) + + assert binding.present is False + assert binding.status == "store_absent" + assert binding.binding_ok is False + + +# --- the change-event count is scoped to THIS repo, not the whole DB +def test_change_event_count_is_repo_scoped(tmp_path: Path) -> None: + repo = tmp_path / "repo" + repo.mkdir() + _populate_store(repo) # 1 event for this repo + # Inject events under a DIFFERENT (phantom) repo_id into the same DB. + conn = sqlite3.connect(default_store_path(repo)) + phantom = store_repo_id(tmp_path / "other-repo") + assert phantom != store_repo_id(repo) + conn.execute( + "INSERT INTO entity_keys(repo_id, locator, sei, first_seen_commit, last_seen_commit) " + "VALUES (?, 'x', NULL, 'p1', 'p1')", + (phantom,), + ) + key_id = conn.execute("SELECT id FROM entity_keys WHERE repo_id = ?", (phantom,)).fetchone()[0] + conn.execute( + "INSERT INTO change_events(repo_id, entity_key_id, commit_sha, path, change_kind, " + "actor, changed_at) VALUES (?, ?, 'p1', 'x', 'modified', 'a', '2026-01-01T00:00:00+00:00')", + (phantom, key_id), + ) + conn.commit() + conn.close() + + binding = read_store_binding(repo) + + assert binding.change_event_count == 1 # only the probed repo's event, not the phantom's + + +# --- store_status is always a member of the closed vocab +def test_store_status_is_always_in_closed_vocab(tmp_path: Path) -> None: + # bound + bound = tmp_path / "bound" + bound.mkdir() + _populate_store(bound) + # absent + absent = tmp_path / "absent" + absent.mkdir() + # corrupt + corrupt = tmp_path / "corrupt" + corrupt.mkdir() + cp = default_store_path(corrupt) + cp.parent.mkdir(parents=True, exist_ok=True) + cp.write_bytes(b"nope") + # schema ahead + ahead = tmp_path / "ahead" + ahead.mkdir() + _populate_store(ahead) + _set_versions(ahead, meta=HIGHEST_KNOWN_VERSION + 9, user_version=HIGHEST_KNOWN_VERSION + 9) + + seen = {read_store_binding(r).status for r in (bound, absent, corrupt, ahead)} + assert seen == {"ok", "store_absent", "store_unreadable", "schema_ahead"} + assert seen <= STORE_STATUS_VOCAB + + +# --- dispatch surfaces the honest store_status + warning for not-bound stores +def test_dispatch_absent_surfaces_status_and_no_warning_noise(tmp_path: Path) -> None: + repo = tmp_path / "repo" + repo.mkdir() + payload = _call_status(repo) + assert payload["data"]["store_status"] == "store_absent" + + +def test_dispatch_schema_ahead_surfaces_status_and_warning(tmp_path: Path) -> None: + repo = tmp_path / "repo" + repo.mkdir() + _populate_store(repo) + ahead = HIGHEST_KNOWN_VERSION + 99 + _set_versions(repo, meta=ahead, user_version=ahead) + + payload = _call_status(repo) + + assert payload["data"]["store_status"] == "schema_ahead" + warnings = payload["warnings"] + assert isinstance(warnings, list) and warnings + assert str(ahead) in warnings[0] # the on-disk version is named explicitly diff --git a/tests/test_propagation.py b/tests/test_propagation.py index f65ca6f..78545f6 100644 --- a/tests/test_propagation.py +++ b/tests/test_propagation.py @@ -43,6 +43,63 @@ def test_blast_radius_walks_downstream(tmp_path: Path) -> None: assert result["affected"][0]["depth"] == 1 +def _chain_store(tmp_path: Path) -> tuple[Path, Path, tuple[int, int, int]]: + """A 3-node call chain a -> b -> c in a FULL snapshot. Returns (repo, db, (a,b,c)).""" + repo = tmp_path / "repo" + repo.mkdir() + db = tmp_path / "warpline.db" + with WarplineStore.open(db) as store: + repo_id = store.ensure_repo(repo) + a = store.ensure_entity_key(repo_id, locator="python:function:a", sei=None, commit_sha="c1") + b = store.ensure_entity_key(repo_id, locator="python:function:b", sei=None, commit_sha="c1") + c = store.ensure_entity_key(repo_id, locator="python:function:c", sei=None, commit_sha="c1") + snap = store.create_edge_snapshot(repo_id, "c1", "loomweave", "test", "FULL") + for src, dst in ((a, b), (b, c)): + store.append_snapshot_edge( + snap, source_entity_key_id=src, target_entity_key_id=dst, + edge_kind="calls", confidence="resolved", + ) + return repo, db, (a, b, c) + + +def test_blast_radius_flags_depth_cap_when_horizon_has_unexplored_edges(tmp_path: Path) -> None: + # depth=1 reaches b but NOT c; b (at the depth horizon) still has an out-edge + # to the unseen c -> the traversal was truncated. + repo, db, (a, b, _c) = _chain_store(tmp_path) + with WarplineStore.open(db) as store: + result = blast_radius(store, repo, [a], depth=1) + assert {row["entity_key_id"] for row in result["affected"]} == {b} # b only + assert result["depth_capped"] is True + + +def test_blast_radius_not_capped_when_full_chain_fits(tmp_path: Path) -> None: + # depth=2 reaches both b and c; c (at the horizon) has no out-edge -> exhaustive. + repo, db, (a, b, c) = _chain_store(tmp_path) + with WarplineStore.open(db) as store: + result = blast_radius(store, repo, [a], depth=2) + assert {row["entity_key_id"] for row in result["affected"]} == {b, c} + assert result["depth_capped"] is False + + +def test_blast_radius_depth_zero_caps_when_downstream_exists(tmp_path: Path) -> None: + repo, db, (a, _b, _c) = _chain_store(tmp_path) + with WarplineStore.open(db) as store: + result = blast_radius(store, repo, [a], depth=0) + assert result["affected"] == [] + assert result["depth_capped"] is True + + +def test_blast_radius_no_snapshot_is_not_depth_capped(tmp_path: Path) -> None: + repo = tmp_path / "repo" + repo.mkdir() + with WarplineStore.open(tmp_path / "warpline.db") as store: + repo_id = store.ensure_repo(repo) + key = store.ensure_entity_key(repo_id, locator="file:a.py", sei=None, commit_sha="c1") + result = blast_radius(store, repo, [key], depth=2) + assert result["completeness"] == "NO_SNAPSHOT" + assert result["depth_capped"] is False + + def test_blast_radius_reports_snapshot_staleness(tmp_path: Path) -> None: repo = tmp_path / "repo" repo.mkdir() diff --git a/tests/test_reason_vocab_conformance.py b/tests/test_reason_vocab_conformance.py index d4f4c63..a225187 100644 --- a/tests/test_reason_vocab_conformance.py +++ b/tests/test_reason_vocab_conformance.py @@ -113,11 +113,11 @@ def test_carrier_non_clean_requires_cause_and_fix(reason_class: str) -> None: assert carrier["cause"] == "why" assert carrier["fix"] == "do this" - with pytest.raises(AssertionError): + with pytest.raises(ValueError): reason(reason_class) # both missing - with pytest.raises(AssertionError): + with pytest.raises(ValueError): reason(reason_class, cause="why") # fix missing - with pytest.raises(AssertionError): + with pytest.raises(ValueError): reason(reason_class, fix="do this") # cause missing @@ -125,5 +125,5 @@ def test_reason_rejects_non_canonical_class() -> None: """reason() refuses to emit a class outside its own closed set — the guard that keeps drift from leaking through the constructor at runtime.""" - with pytest.raises(AssertionError): + with pytest.raises(ValueError): reason("truncated", cause="x", fix="y") # not one of the canonical 11 diff --git a/tests/test_reverify_verification.py b/tests/test_reverify_verification.py new file mode 100644 index 0000000..228b9b3 --- /dev/null +++ b/tests/test_reverify_verification.py @@ -0,0 +1,358 @@ +from __future__ import annotations + +import subprocess +from pathlib import Path + +from warpline import commands +from warpline.store import WarplineStore, default_store_path + + +def _git(repo: Path, *args: str) -> str: + return subprocess.run( + ["git", *args], cwd=repo, check=True, text=True, capture_output=True + ).stdout.strip() + + +def _repo(tmp_path: Path) -> Path: + repo = tmp_path / "r" + repo.mkdir() + _git(repo, "init", "-q") + _git(repo, "config", "user.email", "t@t") + _git(repo, "config", "user.name", "t") + return repo + + +def _commit(repo: Path, name: str, body: str) -> str: + (repo / name).write_text(body) + _git(repo, "add", ".") + _git(repo, "commit", "-q", "-m", f"touch {name}") + return _git(repo, "rev-parse", "HEAD") + + +def _seed_entity_change(store: WarplineStore, repo: Path, locator: str, commit_sha: str) -> int: + repo_id = store.ensure_repo(repo) + key_id = store.ensure_entity_key(repo_id, locator, None, commit_sha) + store.append_change_event( + repo_id=repo_id, + entity_key_id=key_id, + commit_sha=commit_sha, + path="m.py", + change_kind="modified", + actor="dev", + changed_at="2026-06-25T08:00:00+00:00", + ) + return key_id + + +def test_each_item_carries_a_verification_block(tmp_path: Path) -> None: + repo = _repo(tmp_path) + c0 = _commit(repo, "m.py", "v0\n") + with WarplineStore.open(default_store_path(repo)) as store: + key_id = _seed_entity_change(store, repo, "python:function:m.py::f", c0) + env = commands.reverify_worklist(repo, [key_id]) + items = env["data"]["items"] + assert items, "expected a non-empty worklist" + for item in items: + assert "verification" in item + assert item["verification"]["state"] in {"fresh", "stale", "unverified", "unavailable"} + assert "reason_class" in item["verification"]["reason"] + + +def test_unverified_when_no_verification_recorded(tmp_path: Path) -> None: + repo = _repo(tmp_path) + c0 = _commit(repo, "m.py", "v0\n") + with WarplineStore.open(default_store_path(repo)) as store: + key_id = _seed_entity_change(store, repo, "python:function:m.py::f", c0) + env = commands.reverify_worklist(repo, [key_id]) + summary = env["data"]["verification_summary"] + assert summary["local_source_configured"] is False + assert summary["unverified"] >= 1 + item = env["data"]["items"][0] + assert item["verification"]["state"] == "unverified" + assert item["verification"]["reason"]["reason_class"] == "disabled" + + +def test_fresh_when_change_is_verified(tmp_path: Path) -> None: + repo = _repo(tmp_path) + c0 = _commit(repo, "m.py", "v0\n") + with WarplineStore.open(default_store_path(repo)) as store: + key_id = _seed_entity_change(store, repo, "python:function:m.py::f", c0) + commands.verify_record(repo, commit=c0, kind="test_pass", now="2026-06-25T10:00:00+00:00") + env = commands.reverify_worklist(repo, [key_id]) + summary = env["data"]["verification_summary"] + assert summary["local_source_configured"] is True + assert summary["fresh"] >= 1 + assert any(i["reason"] == "changed" for i in env["data"]["items"]), ( + "expected at least one 'changed' item" + ) + item = next(i for i in env["data"]["items"] if i["reason"] == "changed") + assert item["verification"]["state"] == "fresh" + assert item["verification"]["last_verified_commit"] == c0 + + +def test_stale_when_change_lands_after_verification(tmp_path: Path) -> None: + repo = _repo(tmp_path) + c0 = _commit(repo, "m.py", "v0\n") + # Verify at c0, THEN the entity changes again at c1 (uncovered). + commands.verify_record(repo, commit=c0, kind="test_pass", now="2026-06-25T10:00:00+00:00") + c1 = _commit(repo, "m.py", "v1\n") + with WarplineStore.open(default_store_path(repo)) as store: + repo_id = store.ensure_repo(repo) + key_id = store.ensure_entity_key(repo_id, "python:function:m.py::f", None, c0) + for sha in (c0, c1): + store.append_change_event( + repo_id=repo_id, entity_key_id=key_id, commit_sha=sha, path="m.py", + change_kind="modified", actor="dev", changed_at="2026-06-25T08:00:00+00:00", + ) + env = commands.reverify_worklist(repo, [key_id]) + assert any(i["reason"] == "changed" for i in env["data"]["items"]), ( + "expected at least one 'changed' item" + ) + item = next(i for i in env["data"]["items"] if i["reason"] == "changed") + assert item["verification"]["state"] == "stale" + assert env["data"]["verification_summary"]["stale"] >= 1 + + +def test_verification_never_filters_items(tmp_path: Path) -> None: + repo = _repo(tmp_path) + c0 = _commit(repo, "m.py", "v0\n") + with WarplineStore.open(default_store_path(repo)) as store: + key_id = _seed_entity_change(store, repo, "python:function:m.py::f", c0) + baseline = commands.reverify_worklist(repo, [key_id]) + n_before = len(baseline["data"]["items"]) + # Recording verification must never REMOVE an item — only annotate/sort. + commands.verify_record(repo, commit=c0, kind="test_pass") + after = commands.reverify_worklist(repo, [key_id]) + assert len(after["data"]["items"]) == n_before + + +def test_envelope_stays_local_only(tmp_path: Path) -> None: + repo = _repo(tmp_path) + c0 = _commit(repo, "m.py", "v0\n") + with WarplineStore.open(default_store_path(repo)) as store: + key_id = _seed_entity_change(store, repo, "python:function:m.py::f", c0) + env = commands.reverify_worklist(repo, [key_id]) + assert env["meta"]["local_only"] is True + assert env["meta"]["peer_side_effects"] == [] + # verification must NOT have leaked into the frozen enrichment vocab. + assert "verification" not in env["enrichment"] + assert "verification" not in env["enrichment_reasons"] + + +def test_verification_summary_is_post_filter_zero_case(tmp_path: Path) -> None: + # Our entity has sei=None; filtering has_sei -> empty set -> all-zero summary. + repo = _repo(tmp_path) + c0 = _commit(repo, "m.py", "v0\n") + with WarplineStore.open(default_store_path(repo)) as store: + key_id = _seed_entity_change(store, repo, "python:function:m.py::f", c0) + env = commands.reverify_worklist(repo, [key_id], filters={"has_sei": True}) + assert env["data"]["items"] == [] + summary = env["data"]["verification_summary"] + assert summary["fresh"] == 0 + assert summary["stale"] == 0 + assert summary["unverified"] == 0 + assert summary["unavailable"] == 0 + + +def test_verification_summary_counts_only_filtered_subset(tmp_path: Path) -> None: + # Two entities; one HAS an sei, one does not. Filtering has_sei=True keeps + # exactly ONE. The summary must count 1, not 2 — proving it is computed on the + # POST-filter set (a pre-filter computation would report 2). + repo = _repo(tmp_path) + c0 = _commit(repo, "m.py", "v0\n") + with WarplineStore.open(default_store_path(repo)) as store: + repo_id = store.ensure_repo(repo) + with_sei = store.ensure_entity_key(repo_id, "python:function:m.py::f", "lw:eid:has", c0) + without_sei = store.ensure_entity_key(repo_id, "python:function:m.py::g", None, c0) + for kid in (with_sei, without_sei): + store.append_change_event( + repo_id=repo_id, entity_key_id=kid, commit_sha=c0, path="m.py", + change_kind="modified", actor="dev", changed_at="2026-06-25T08:00:00+00:00", + ) + env = commands.reverify_worklist(repo, [with_sei, without_sei], filters={"has_sei": True}) + assert len(env["data"]["items"]) == 1 + summary = env["data"]["verification_summary"] + total = summary["fresh"] + summary["stale"] + summary["unverified"] + summary["unavailable"] + assert total == 1 # NOT 2 — proves post-filter computation + + +def test_unavailable_when_reachability_fails(tmp_path: Path, monkeypatch) -> None: + repo = _repo(tmp_path) + c0 = _commit(repo, "m.py", "v0\n") + # A verification event must exist so covers() is actually consulted. + commands.verify_record(repo, commit=c0, kind="test_pass", now="2026-06-25T10:00:00+00:00") + with WarplineStore.open(default_store_path(repo)) as store: + key_id = _seed_entity_change(store, repo, "python:function:m.py::f", c0) + # Patch the name in commands' namespace (it imported is_ancestor by name). + monkeypatch.setattr(commands, "is_ancestor", lambda *a, **k: None) + env = commands.reverify_worklist(repo, [key_id]) + assert any(i["reason"] == "changed" for i in env["data"]["items"]), ( + "expected at least one 'changed' item" + ) + item = next(i for i in env["data"]["items"] if i["reason"] == "changed") + assert item["verification"]["state"] == "unavailable" + assert item["verification"]["reason"]["reason_class"] == "unreachable" + assert env["data"]["verification_summary"]["unavailable"] >= 1 + + +def test_stale_sorts_before_fresh_by_default(tmp_path: Path) -> None: + repo = _repo(tmp_path) + c0 = _commit(repo, "a.py", "v0\n") + commands.verify_record(repo, commit=c0, kind="test_pass", now="2026-06-25T10:00:00+00:00") + c1 = _commit(repo, "b.py", "v1\n") + with WarplineStore.open(default_store_path(repo)) as store: + repo_id = store.ensure_repo(repo) + a = store.ensure_entity_key(repo_id, "python:function:a.py::fa", None, c0) + store.append_change_event( + repo_id=repo_id, entity_key_id=a, commit_sha=c0, path="a.py", + change_kind="modified", actor="dev", changed_at="2026-06-25T08:00:00+00:00", + ) + b = store.ensure_entity_key(repo_id, "python:function:b.py::fb", None, c0) + for sha in (c0, c1): + store.append_change_event( + repo_id=repo_id, entity_key_id=b, commit_sha=sha, path="b.py", + change_kind="modified", actor="dev", changed_at="2026-06-25T08:00:00+00:00", + ) + # Seed FRESH first so the natural (pre-presort) order is fresh-then-stale; + # the presort must flip it. (Catches presort removal: without it the order + # stays fresh-first and this assertion fails.) + env = commands.reverify_worklist(repo, [a, b]) + states = [i["verification"]["state"] for i in env["data"]["items"]] + assert "stale" in states and "fresh" in states + assert states.index("stale") < states.index("fresh") # advisory: stale first + + +def test_fresh_when_verified_at_a_later_commit(tmp_path: Path) -> None: + # Asymmetric real-git case that catches a covers/is_ancestor argument SWAP: + # change at c0, verify at c1 (c1 is a DESCENDANT of c0). c0 is an ancestor of + # c1, so the change IS covered -> fresh. A swapped is_ancestor(verified, change) + # would compute is_ancestor(c1, c0) -> False and wrongly report not-fresh. + repo = _repo(tmp_path) + c0 = _commit(repo, "m.py", "v0\n") + c1 = _commit(repo, "n.py", "v0\n") # later commit, descendant of c0 + with WarplineStore.open(default_store_path(repo)) as store: + key_id = _seed_entity_change(store, repo, "python:function:m.py::f", c0) + commands.verify_record(repo, commit=c1, kind="test_pass", now="2026-06-25T10:00:00+00:00") + env = commands.reverify_worklist(repo, [key_id]) + assert any(i["reason"] == "changed" for i in env["data"]["items"]), ( + "expected at least one 'changed' item" + ) + item = next(i for i in env["data"]["items"] if i["reason"] == "changed") + assert item["verification"]["state"] == "fresh" + assert item["verification"]["last_verified_commit"] == c1 + + +def test_unavailable_when_change_commit_no_longer_exists(tmp_path: Path) -> None: + # Squash/rebase honesty: a change_event whose commit SHA was rewritten away + # (no longer a real object). With a recorded verification, git reachability + # cannot be computed -> 'unavailable'/'unreachable', NOT a silent 'unverified' + # (which would falsely imply "just needs verifying" instead of "trust unknown"). + repo = _repo(tmp_path) + c0 = _commit(repo, "m.py", "v0\n") + commands.verify_record(repo, commit=c0, kind="test_pass", now="2026-06-25T10:00:00+00:00") + ghost = "0" * 40 # a SHA that never existed (rewritten by squash/rebase) + with WarplineStore.open(default_store_path(repo)) as store: + repo_id = store.ensure_repo(repo) + key_id = store.ensure_entity_key(repo_id, "python:function:m.py::f", None, ghost) + store.append_change_event( + repo_id=repo_id, entity_key_id=key_id, commit_sha=ghost, path="m.py", + change_kind="modified", actor="dev", changed_at="2026-06-25T08:00:00+00:00", + ) + env = commands.reverify_worklist(repo, [key_id]) + assert any(i["reason"] == "changed" for i in env["data"]["items"]), ( + "expected at least one 'changed' item" + ) + item = next(i for i in env["data"]["items"] if i["reason"] == "changed") + assert item["verification"]["state"] == "unavailable" + assert item["verification"]["reason"]["reason_class"] == "unreachable" + + +def test_stale_first_is_secondary_to_an_explicit_sort(tmp_path: Path) -> None: + # Proves stale-first is a SECONDARY tiebreak, not a primary override. + # Uses the same edge-snapshot setup as test_gv_wl_3 (golden_vectors.py:331-351) + # to produce a depth-1 downstream item. + # + # Layout: + # X (depth=0, changed, FRESH): changed at c0, verified at c0 + # Z (depth=0, changed, STALE): changed at c0 and c1 (after verification) + # Y (depth=1, downstream, STALE): changed at c0, then at c1 (after verification) + # + # With default sort (depth asc), both X and Z (depth=0) must precede Y (depth=1) + # even though Y is stale and X is fresh. This proves stale-first advisory + # sort is the SECONDARY key (within ties), not the primary key. + # + # Within depth=0, Z (stale) must precede X (fresh) — this is assertion (c). + # + # If the presort were placed AFTER apply_sort instead of before, apply_sort + # would undo the depth ordering and this assertion would fail. + repo = _repo(tmp_path) + c0 = _commit(repo, "x.py", "v0\n") + # Verify at c0 so X is fresh; Z and Y both have a later change (c1) -> stale. + commands.verify_record(repo, commit=c0, kind="test_pass", now="2026-06-25T10:00:00+00:00") + c1 = _commit(repo, "y.py", "v1\n") # later commit; Z and Y change AFTER verification + head = c1 + with WarplineStore.open(default_store_path(repo)) as store: + repo_id = store.ensure_repo(repo) + # X: depth=0, changed entity — fresh (verified at c0, changed only at c0) + x = store.ensure_entity_key(repo_id, "python:function:x.py::fx", None, c0) + store.append_change_event( + repo_id=repo_id, entity_key_id=x, commit_sha=c0, path="x.py", + change_kind="modified", actor="dev", changed_at="2026-06-25T08:00:00+00:00", + ) + # Z: depth=0, changed entity — stale (changed at c0 and c1, only c0 covered) + z = store.ensure_entity_key(repo_id, "python:function:z.py::fz", None, c0) + for sha in (c0, c1): + store.append_change_event( + repo_id=repo_id, entity_key_id=z, commit_sha=sha, path="z.py", + change_kind="modified", actor="dev", changed_at="2026-06-25T08:00:00+00:00", + ) + # Y: depth=1, downstream entity — stale (changed at c0 and c1, only c0 covered) + y = store.ensure_entity_key(repo_id, "python:function:y.py::fy", None, c0) + for sha in (c0, c1): + store.append_change_event( + repo_id=repo_id, entity_key_id=y, commit_sha=sha, path="y.py", + change_kind="modified", actor="dev", changed_at="2026-06-25T08:00:00+00:00", + ) + # Mirror test_gv_wl_3: create a FULL edge snapshot with x->y calls edge + # so blast_radius produces Y as a depth-1 affected item. + snap = store.create_edge_snapshot(repo_id, head, "loomweave", "test", "FULL") + store.append_snapshot_edge( + snap, source_entity_key_id=x, target_entity_key_id=y, + edge_kind="calls", confidence="resolved", + ) + x_id = x + env = commands.reverify_worklist(repo, [x_id, z], depth=2) + items = env["data"]["items"] + depths = [it["depth"] for it in items] + states = [it["verification"]["state"] for it in items] + + # (a) depth stays the PRIMARY ordering — depth 0 before depth 1 + assert depths == sorted(depths), f"depth ordering violated: {depths}" + + # (b) depth-0 fresh item precedes depth-1 stale item + x_item = next( + (it for it in items if it["depth"] == 0 and it["verification"]["state"] == "fresh"), None + ) + y_item = next((it for it in items if it["depth"] == 1), None) + z_item = next( + (it for it in items if it["depth"] == 0 and it["verification"]["state"] == "stale"), None + ) + assert x_item is not None, "expected depth-0 fresh item (X)" + assert z_item is not None, "expected depth-0 stale item (Z)" + assert y_item is not None, "expected depth-1 item (Y)" + assert items.index(x_item) < items.index(y_item), ( + "stale-first presort must NOT override depth primary key" + ) + assert items.index(z_item) < items.index(y_item), ( + "depth-0 stale item must precede depth-1 item" + ) + + # (c) within depth=0, stale (Z) precedes fresh (X) — same-depth tiebreak + assert items.index(z_item) < items.index(x_item), ( + "within depth=0, stale item Z must precede fresh item X" + ) + + # Verify the states we observed + assert x_item["verification"]["state"] == "fresh", f"X should be fresh, got {states}" + assert z_item["verification"]["state"] == "stale", f"Z should be stale, got {states}" + assert y_item["verification"]["state"] == "stale", f"Y should be stale, got {states}" diff --git a/tests/test_sei_resolution.py b/tests/test_sei_resolution.py index 18cb013..b74290d 100644 --- a/tests/test_sei_resolution.py +++ b/tests/test_sei_resolution.py @@ -5,6 +5,7 @@ from warpline.loomweave import ( loomweave_entity_id_candidates, loomweave_resolve_qualnames, + resolve_content_hash_for_locator, resolve_sei_for_locator, ) from warpline.store import WarplineStore @@ -53,6 +54,45 @@ def test_resolve_sei_for_locator_returns_opaque_value() -> None: ) +class _HashClient: + """entity_resolve carrying BOTH sei and content_hash on the candidate — the + REAL shape (verified live: candidate.content_hash is loomweave's per-entity + body hash, the same value wardline binds into a wardline-attest-2 boundary).""" + + def __init__(self, content_hash: str | None = "42f3670fbeefbeefbeef") -> None: + self._chash = content_hash + + def call_tool(self, name: str, arguments: dict[str, object]) -> dict[str, object]: + assert name == "entity_resolve" + return { + "results": [ + { + "qualname": "pkg.mod.fn", + "result_kind": "resolved", + "candidates": [ + { + "id": "python:function:pkg.mod.fn", + "sei": "loomweave:eid:opaque-value", + "content_hash": self._chash, + } + ], + } + ] + } + + +def test_resolve_content_hash_for_locator_reads_loomweave_body_hash() -> None: + assert ( + resolve_content_hash_for_locator(_HashClient(), "python:function:pkg/mod.py::fn") + == "42f3670fbeefbeefbeef" + ) + + +def test_resolve_content_hash_absent_is_none_not_guessed() -> None: + client = _HashClient(content_hash=None) + assert resolve_content_hash_for_locator(client, "python:function:pkg/mod.py::fn") is None + + def test_loomweave_entity_id_candidates_translate_python_locators() -> None: assert loomweave_entity_id_candidates("python:function:pkg/mod.py::Class.fn") == [ "python:function:pkg.mod.Class.fn", diff --git a/tests/test_sibling_boundaries.py b/tests/test_sibling_boundaries.py index aee8524..04a479a 100644 --- a/tests/test_sibling_boundaries.py +++ b/tests/test_sibling_boundaries.py @@ -5,7 +5,7 @@ import subprocess from pathlib import Path -FORBIDDEN_IMPORT_ROOTS = {"filigree", "wardline", "legis", "loomweave", "charter"} +FORBIDDEN_IMPORT_ROOTS = {"filigree", "wardline", "legis", "loomweave", "plainweave"} def test_warpline_does_not_import_sibling_packages() -> None: diff --git a/tests/test_snapshots.py b/tests/test_snapshots.py index f82dfce..cc977d6 100644 --- a/tests/test_snapshots.py +++ b/tests/test_snapshots.py @@ -293,7 +293,11 @@ def test_capture_edge_snapshot_maps_loomweave_ids_back_to_warpline_keys(tmp_path } in edges -def test_capture_edge_snapshot_clears_edges_on_recapture(tmp_path: Path) -> None: +def test_capture_skipped_preserves_prior_full_snapshot(tmp_path: Path) -> None: + """Loomweave absent at re-capture must NOT downgrade a usable prior FULL + snapshot to a 0-edge SKIPPED row (R3 data-loss). The prior graph is real; + overwriting it with "we don't know" is strictly worse. Preserve it intact + and report the recapture as skipped (fail-closed / enrich-only doctrine).""" repo = tmp_path / "repo" repo.mkdir() with WarplineStore.open(tmp_path / "warpline.db") as store: @@ -304,29 +308,97 @@ def test_capture_edge_snapshot_clears_edges_on_recapture(tmp_path: Path) -> None b = store.ensure_entity_key( repo_id, locator="python:function:b", sei=None, commit_sha="c1" ) - snapshot_id = store.create_edge_snapshot(repo_id, "c1", "loomweave", "old", "FULL") + prior_id = store.create_edge_snapshot(repo_id, "c1", "loomweave", "old", "FULL") store.append_snapshot_edge( - snapshot_id, + prior_id, source_entity_key_id=a, target_entity_key_id=b, edge_kind="calls", confidence="resolved", ) - capture_edge_snapshot( + result = capture_edge_snapshot( store, repo, commit_sha="c1", client=None, source_version="no_index", ) - edges = store.snapshot_edges(snapshot_id) + edges = store.snapshot_edges(prior_id) snapshot = store.latest_snapshot(repo) - assert edges == [] + # The prior FULL snapshot and its edge survive untouched. + assert len(edges) == 1 assert snapshot is not None + assert int(snapshot["id"]) == prior_id + assert snapshot["completeness"] == "FULL" + assert snapshot["source_version"] == "old" + # The capture result honestly reports the preserved row, not a fresh SKIPPED. + assert result["snapshot_id"] == prior_id + assert result["completeness"] == "FULL" + assert result["recapture_skipped"] is True + assert result["entities"] == 0 + assert result["edges"] == 0 + + +def test_capture_skipped_preserves_prior_delta_snapshot(tmp_path: Path) -> None: + """A partial-but-real DELTA prior is also preserved (not downgraded to + SKIPPED) when loomweave is absent at re-capture — its edges are still real.""" + repo = tmp_path / "repo" + repo.mkdir() + with WarplineStore.open(tmp_path / "warpline.db") as store: + repo_id = store.ensure_repo(repo) + a = store.ensure_entity_key( + repo_id, locator="python:function:a", sei=None, commit_sha="c1" + ) + b = store.ensure_entity_key( + repo_id, locator="python:function:b", sei=None, commit_sha="c1" + ) + prior_id = store.create_edge_snapshot(repo_id, "c1", "loomweave", "old", "DELTA") + store.append_snapshot_edge( + prior_id, + source_entity_key_id=a, + target_entity_key_id=b, + edge_kind="calls", + confidence="resolved", + ) + + result = capture_edge_snapshot( + store, repo, commit_sha="c1", client=None, source_version="no_index" + ) + edges = store.snapshot_edges(prior_id) + snapshot = store.latest_snapshot(repo) + + assert len(edges) == 1 + assert snapshot is not None + assert snapshot["completeness"] == "DELTA" + assert snapshot["source_version"] == "old" + assert result["completeness"] == "DELTA" + assert result["recapture_skipped"] is True + + +def test_capture_skipped_without_prior_writes_skipped_atomically(tmp_path: Path) -> None: + """With no usable prior, a loomweave-absent capture records a single SKIPPED + row (no edges), written in one transaction — not the old two-commit + (UPSERT then DELETE) dance. There is nothing to corrupt, so SKIPPED is the + honest 'we have nothing' marker here.""" + repo = tmp_path / "repo" + repo.mkdir() + with WarplineStore.open(tmp_path / "warpline.db") as store: + store.ensure_repo(repo) + result = capture_edge_snapshot( + store, repo, commit_sha="c1", client=None, source_version="no_index" + ) + snapshot = store.latest_snapshot(repo) + assert snapshot is not None + edges = store.snapshot_edges(int(snapshot["id"])) + assert snapshot["completeness"] == "SKIPPED" assert snapshot["source_version"] == "no_index" + assert edges == [] + assert result["completeness"] == "SKIPPED" + assert result["snapshot_id"] == int(snapshot["id"]) + assert "recapture_skipped" not in result def test_capture_edge_snapshot_batches_edge_writes(tmp_path: Path) -> None: diff --git a/tests/test_store.py b/tests/test_store.py index 0f1df28..f71dabd 100644 --- a/tests/test_store.py +++ b/tests/test_store.py @@ -23,7 +23,7 @@ def test_default_store_path_honors_explicit_store_dir(tmp_path: Path) -> None: def test_store_initializes_schema(tmp_path: Path) -> None: db = tmp_path / "warpline.db" with WarplineStore.open(db) as store: - assert store.schema_version() == 3 + assert store.schema_version() == 4 def test_store_writes_nested_gitignore_that_ignores_runtime_db(tmp_path: Path) -> None: @@ -32,7 +32,7 @@ def test_store_writes_nested_gitignore_that_ignores_runtime_db(tmp_path: Path) - subprocess.run(["git", "init"], cwd=repo, check=True, text=True, capture_output=True) with WarplineStore.open(default_store_path(repo)) as store: - assert store.schema_version() == 3 + assert store.schema_version() == 4 gitignore = repo / ".weft" / "warpline" / ".gitignore" assert gitignore.exists() diff --git a/tests/test_store_migrations.py b/tests/test_store_migrations.py index 7f8bed2..39c4fa5 100644 --- a/tests/test_store_migrations.py +++ b/tests/test_store_migrations.py @@ -1,11 +1,11 @@ """Rung 1a: ordered migration runner + PRAGMA hardening. The base SCHEMA is FROZEN after Rung 1a; all schema change lands via the ordered -``MIGRATIONS`` list. As of Rung 1b the real list carries v2 (anchor columns), so -the highest known version is 2. The runner mechanics (ordering, atomicity, -idempotence, concurrency safety) are still exercised against synthetic -migrations monkeypatched onto the module so they stay decoupled from any single -shipped version. +``MIGRATIONS`` list. As of Rung 2 Track B the real list carries v2 (anchor +columns), v3 (co_change_pairs), and v4 (verification_events), so the highest +known version is 4. The runner mechanics (ordering, atomicity, idempotence, +concurrency safety) are still exercised against synthetic migrations monkeypatched +onto the module so they stay decoupled from any single shipped version. """ from __future__ import annotations @@ -42,10 +42,10 @@ def test_fresh_db_lands_at_highest_known_version(tmp_path: Path) -> None: db = tmp_path / "warpline.db" with WarplineStore.open(db) as store: assert store.schema_version() == store_mod.HIGHEST_KNOWN_VERSION - # As of Rung 2 Track A the highest known version is 3 (co_change_pairs; - # v2 anchor columns + v3 co-change graph). + # As of Rung 2 Track B the highest known version is 4 (verification_events; + # v2 anchor columns + v3 co-change graph + v4 verification-freshness events). assert _user_version(db) == store_mod.HIGHEST_KNOWN_VERSION - assert store_mod.HIGHEST_KNOWN_VERSION == 3 + assert store_mod.HIGHEST_KNOWN_VERSION == 4 def test_connection_pragmas_are_hardened(tmp_path: Path) -> None: @@ -122,13 +122,13 @@ def test_user_version_zero_with_divergent_meta_adopts_and_warns(tmp_path: Path) """M9: user_version==0 but meta.schema_version!='1' → adopt meta value + warn. The marker is only TRUSTED when the schema objects it implies are actually - present (#9). Here a fully-migrated DB (all v2/v3 objects on disk) is forged + present (#9). Here a fully-migrated DB (all v2/v3/v4 objects on disk) is forged with an ahead marker '5' and user_version=0 — a genuinely-newer writer whose - extra v(>3) objects we cannot enumerate — so the marker is adopted as-is and + extra v(>4) objects we cannot enumerate — so the marker is adopted as-is and flagged ahead, NOT floored away. """ db = tmp_path / "warpline.db" - # Materialize the real schema (lands at v3 with anchor columns + co_change_pairs). + # Materialize the real schema (v4: anchor columns + co_change_pairs + verification_events). with WarplineStore.open(db): pass # Forge an ahead marker with user_version reset to 0 (divergent newer writer). @@ -140,7 +140,7 @@ def test_user_version_zero_with_divergent_meta_adopts_and_warns(tmp_path: Path) with WarplineStore.open(db) as store: # Adopted 5 from meta (objects present → marker trusted); 5 > highest - # known (3), so it is also flagged ahead. + # known (4), so it is also flagged ahead. assert store.schema_version() == 5 codes = _health_codes(db) assert "MIGRATION_META_RECONCILE" in codes @@ -163,20 +163,21 @@ def _table_names(db: Path) -> set[str]: def test_inflated_meta_without_schema_objects_reruns_migrations(tmp_path: Path) -> None: - """#9: meta.schema_version='3' but NO co_change_pairs table → migrations re-run. + """#9: meta.schema_version='4' but NO co_change_pairs table → migrations re-run. - A DB whose meta marker CLAIMS v3 but is missing the objects v3 implies must - not come up "at v3" — the next coupling query would raise ``no such table``. + A DB whose meta marker CLAIMS v4 but is missing the objects v4 implies must + not come up "at v4" — the next coupling query would raise ``no such table``. The runner sanity-checks object presence, floors to the verified version, and re-applies the missing steps, so after open() the table actually exists. """ db = tmp_path / "warpline.db" - # Base SCHEMA only: no anchor columns (v2), no co_change_pairs (v3). Then forge - # an inflated marker with user_version=0 (pre-runner / divergent writer). + # Base SCHEMA only: no anchor columns (v2), no co_change_pairs (v3), no + # verification_events (v4). Then forge an inflated marker with user_version=0 + # (pre-runner / divergent writer). raw = sqlite3.connect(db) raw.executescript(SCHEMA) - raw.execute("UPDATE meta SET value = '3' WHERE key = 'schema_version'") + raw.execute("UPDATE meta SET value = '4' WHERE key = 'schema_version'") raw.execute("PRAGMA user_version = 0") raw.commit() raw.close() @@ -191,6 +192,10 @@ def test_inflated_meta_without_schema_objects_reruns_migrations(tmp_path: Path) "SELECT COUNT(*) AS c FROM co_change_pairs" ).fetchone()["c"] assert n == 0 + # The v4 verification_events table is also present after re-running. + assert store.conn.execute( + "SELECT 1 FROM sqlite_master WHERE type='table' AND name='verification_events'" + ).fetchone() is not None # The v2 anchor columns are present too (floored below v2 as well). cols = {r["name"] for r in store.conn.execute("PRAGMA table_info(change_events)")} assert {"detected_branch", "detected_context"} <= cols diff --git a/tests/test_verification_compose.py b/tests/test_verification_compose.py new file mode 100644 index 0000000..66e5742 --- /dev/null +++ b/tests/test_verification_compose.py @@ -0,0 +1,172 @@ +from __future__ import annotations + +from warpline.verification import compose_verification_freshness + + +def _covers_set(covered_pairs: set[tuple[str, str]]): + """covers(V, C) True iff (V, C) in the set; default False.""" + + def covers(verified: str, change: str) -> bool | None: + return (verified, change) in covered_pairs + + return covers + + +def _between_const(value): + def between(ancestor: str, descendant: str) -> int | None: + return value + + return between + + +def test_empty_changes_is_unverified() -> None: + out = compose_verification_freshness([], [], _covers_set(set()), _between_const(0)) + assert out["state"] == "unverified" + assert out["reason"]["reason_class"] == "disabled" + assert out["reason"]["cause"] and out["reason"]["fix"] + assert out["decay"]["commits_behind"] is None + + +def test_fresh_when_latest_change_covered() -> None: + events = [{"commit_sha": "V1", "verified_at": "2026-06-25T10:00:00+00:00"}] + out = compose_verification_freshness( + ["C0", "C1"], events, _covers_set({("V1", "C1"), ("V1", "C0")}), _between_const(5) + ) + assert out["state"] == "fresh" + assert out["last_verified_commit"] == "V1" + assert out["last_verified_at"] == "2026-06-25T10:00:00+00:00" + assert out["decay"]["commits_behind"] == 0 + assert out["reason"]["reason_class"] == "clean" + + +def test_stale_when_only_earlier_change_covered() -> None: + events = [{"commit_sha": "V1", "verified_at": "2026-06-25T10:00:00+00:00"}] + # V1 covers C0 (earlier) but NOT C1 (latest). + out = compose_verification_freshness( + ["C0", "C1"], events, _covers_set({("V1", "C0")}), _between_const(2) + ) + assert out["state"] == "stale" + assert out["last_verified_commit"] == "V1" + assert out["last_verified_at"] == "2026-06-25T10:00:00+00:00" + assert out["decay"]["commits_behind"] == 2 + assert out["reason"]["reason_class"] == "stale" + assert out["reason"]["cause"] and out["reason"]["fix"] + + +def test_unverified_when_no_event_covers_any_change() -> None: + events = [{"commit_sha": "V1", "verified_at": "2026-06-25T10:00:00+00:00"}] + out = compose_verification_freshness( + ["C0", "C1"], events, _covers_set(set()), _between_const(0) + ) + assert out["state"] == "unverified" + assert out["last_verified_commit"] is None + assert out["decay"]["commits_behind"] is None + assert out["reason"]["reason_class"] == "disabled" + + +def test_unverified_when_no_events_at_all() -> None: + out = compose_verification_freshness( + ["C0"], [], _covers_set(set()), _between_const(0) + ) + assert out["state"] == "unverified" + assert out["reason"]["reason_class"] == "disabled" + + +def test_unavailable_when_reachability_undetermined() -> None: + events = [{"commit_sha": "V1", "verified_at": "2026-06-25T10:00:00+00:00"}] + + def covers(verified: str, change: str) -> bool | None: + return None # git could not compute (shallow clone / missing commit) + + out = compose_verification_freshness(["C0", "C1"], events, covers, _between_const(0)) + assert out["state"] == "unavailable" + assert out["last_verified_commit"] is None + assert out["decay"]["commits_behind"] is None + assert out["reason"]["reason_class"] == "unreachable" + assert out["reason"]["cause"] and out["reason"]["fix"] + + +def test_most_recent_covering_event_wins_last_verified() -> None: + events = [ + {"commit_sha": "V1", "verified_at": "2026-06-25T09:00:00+00:00"}, + {"commit_sha": "V2", "verified_at": "2026-06-25T11:00:00+00:00"}, + ] + # Both cover latest; the later-verified_at one is reported. + out = compose_verification_freshness( + ["C1"], events, _covers_set({("V1", "C1"), ("V2", "C1")}), _between_const(0) + ) + assert out["state"] == "fresh" + assert out["last_verified_commit"] == "V2" + assert out["last_verified_at"] == "2026-06-25T11:00:00+00:00" + + +def test_unavailable_when_latest_undetermined_even_if_earlier_covered() -> None: + # Fail-soft precedence: if git cannot decide the LATEST change's coverage, + # the state is 'unavailable' even when an EARLIER change is covered — never + # 'stale' (which would falsely imply we KNOW it drifted) and never 'fresh'. + events = [{"commit_sha": "V1", "verified_at": "2026-06-25T10:00:00+00:00"}] + + def covers(verified: str, change: str) -> bool | None: + if change == "C1": + return None # latest change: git cannot decide + return True # earlier change C0: covered + + out = compose_verification_freshness(["C0", "C1"], events, covers, _between_const(0)) + assert out["state"] == "unavailable" + assert out["reason"]["reason_class"] == "unreachable" + assert out["last_verified_at"] is None + assert out["decay"]["commits_behind"] is None + + +def test_unavailable_when_earlier_change_undetermined() -> None: + # Fail-soft on the EARLIER branch: latest change is definitively uncovered, + # but an earlier change's coverage is undetermined — never claim 'unverified' + # (an earned clean-empty) when git could not decide. + events = [{"commit_sha": "V1", "verified_at": "2026-06-25T10:00:00+00:00"}] + + def covers(verified: str, change: str) -> bool | None: + if change == "C1": # latest: definitively NOT covered + return False + return None # earlier change: undetermined + + out = compose_verification_freshness(["C0", "C1"], events, covers, _between_const(0)) + assert out["state"] == "unavailable" + assert out["reason"]["reason_class"] == "unreachable" + assert out["last_verified_commit"] is None + + +def test_fresh_when_one_event_covers_latest_and_another_is_undetermined() -> None: + # Precedence: a MIXED covers() result on the LATEST change (one event returns + # None/undetermined, another returns True) must yield 'fresh' — a positive + # cover wins; the None is irrelevant once a True exists. + events = [ + {"commit_sha": "V1", "verified_at": "2026-06-25T09:00:00+00:00"}, + {"commit_sha": "V2", "verified_at": "2026-06-25T11:00:00+00:00"}, + ] + + def covers(verified: str, change: str) -> bool | None: + return None if verified == "V1" else True # V1 undetermined, V2 covers + + out = compose_verification_freshness(["C1"], events, covers, _between_const(0)) + assert out["state"] == "fresh" + assert out["last_verified_commit"] == "V2" + + +def test_stale_decay_uses_tightest_cover_not_latest_recorded() -> None: + # Two covering events for earlier changes, recorded OUT of git-ancestry order: + # V_new is a more-advanced commit (tighter cover, 1 commit behind) recorded + # FIRST; V_old is a less-advanced commit (6 behind) recorded LATER. Decay must + # use the tightest cover (V_new -> 1), not the latest-recorded (V_old -> 6). + events = [ + {"commit_sha": "V_new", "verified_at": "2026-06-25T10:00:00+00:00"}, # recorded first + {"commit_sha": "V_old", "verified_at": "2026-06-25T12:00:00+00:00"}, # recorded later + ] + changes = ["C0", "C1", "C2"] # latest = C2, uncovered by both + def covers(verified: str, change: str) -> bool | None: + return False if change == "C2" else True # both cover earlier changes only + def between(ancestor: str, descendant: str) -> int | None: + return {"V_new": 1, "V_old": 6}.get(ancestor) # distance from each cover to C2 + out = compose_verification_freshness(changes, events, covers, between) + assert out["state"] == "stale" + assert out["last_verified_commit"] == "V_new" # tightest cover, NOT latest-recorded V_old + assert out["decay"]["commits_behind"] == 1 diff --git a/tests/test_verification_store.py b/tests/test_verification_store.py new file mode 100644 index 0000000..f48ee95 --- /dev/null +++ b/tests/test_verification_store.py @@ -0,0 +1,204 @@ +from __future__ import annotations + +import sqlite3 +from pathlib import Path + +from warpline.store import HIGHEST_KNOWN_VERSION, WarplineStore, default_store_path + + +def _open(tmp_path: Path) -> WarplineStore: + return WarplineStore.open(default_store_path(tmp_path)) + + +def test_schema_reaches_version_4(tmp_path: Path) -> None: + with _open(tmp_path) as store: + version = store.conn.execute("PRAGMA user_version").fetchone()[0] + assert int(version) == 4 + assert HIGHEST_KNOWN_VERSION == 4 + + +def test_verification_events_table_exists(tmp_path: Path) -> None: + with _open(tmp_path) as store: + row = store.conn.execute( + "SELECT 1 FROM sqlite_master WHERE type='table' AND name='verification_events'" + ).fetchone() + assert row is not None + + +def test_reopen_is_idempotent(tmp_path: Path) -> None: + path = default_store_path(tmp_path) + with WarplineStore.open(path) as store: + store.conn.execute("PRAGMA user_version").fetchone() + # Re-open: no migration re-runs, no error, still v4. + with WarplineStore.open(path) as store: + assert int(store.conn.execute("PRAGMA user_version").fetchone()[0]) == 4 + + +def test_presence_floor_recovers_dropped_table(tmp_path: Path) -> None: + """#9: a v4 marker claiming the schema but missing verification_events floors + to v3 and re-runs the v4 migration via the user_version==0 reconcile path. + + This exercises the SAME presence-floor recovery the runner already guards for + v3 (see test_inflated_meta_without_schema_objects_reruns_migrations): forge a + DB whose meta marker CLAIMS v4 but whose verification_events table is gone, + with user_version=0 so the reconcile path runs. The v2 anchor columns and v3 + co_change_pairs table are left intact, so the floor lands at exactly 3 and + only the v4 migration re-runs. + """ + + path = default_store_path(tmp_path) + # Materialize the full v4 schema, then forge the dropped-table scenario. + with WarplineStore.open(path) as store: + assert store.schema_version() == 4 + raw = sqlite3.connect(path) + raw.row_factory = sqlite3.Row + raw.execute("DROP TABLE verification_events") + # Claim v4 in meta but reset user_version to 0 so the reconcile path runs. + raw.execute("UPDATE meta SET value = '4' WHERE key = 'schema_version'") + raw.execute("PRAGMA user_version = 0") + raw.commit() + # v2/v3 objects must remain intact so only v4 re-runs (not v2/v3). + cols_before = {r["name"] for r in raw.execute("PRAGMA table_info(change_events)")} + assert {"detected_branch", "detected_context"} <= cols_before + tables_before = { + str(r[0]) + for r in raw.execute("SELECT name FROM sqlite_master WHERE type='table'") + } + assert "co_change_pairs" in tables_before + assert "verification_events" not in tables_before + raw.close() + + # Re-open: presence-floor floors to v3 and re-runs v4. + with WarplineStore.open(path) as store: + assert store.schema_version() == 4 + row = store.conn.execute( + "SELECT 1 FROM sqlite_master WHERE type='table' AND name='verification_events'" + ).fetchone() + assert row is not None + # v2/v3 objects were NOT collaterally dropped or rebuilt-from-empty in a way + # that loses data: co_change_pairs still exists and anchor columns persist. + assert store.conn.execute( + "SELECT 1 FROM sqlite_master WHERE type='table' AND name='co_change_pairs'" + ).fetchone() is not None + cols_after = {r["name"] for r in store.conn.execute("PRAGMA table_info(change_events)")} + assert {"detected_branch", "detected_context"} <= cols_after + + +def test_record_and_list_round_trip(tmp_path: Path) -> None: + with _open(tmp_path) as store: + repo_id = store.ensure_repo(tmp_path) + store.record_verification_event( + repo_id=repo_id, + commit_sha="a" * 40, + kind="test_pass", + verified_at="2026-06-25T10:00:00+00:00", + actor="ci-bot", + source="warpline", + ) + events = store.list_verification_events(tmp_path) + assert len(events) == 1 + assert events[0]["commit_sha"] == "a" * 40 + assert events[0]["kind"] == "test_pass" + assert events[0]["actor"] == "ci-bot" + assert events[0]["source"] == "warpline" + + +def test_record_is_idempotent_on_unique_key(tmp_path: Path) -> None: + with _open(tmp_path) as store: + repo_id = store.ensure_repo(tmp_path) + for _ in range(2): + store.record_verification_event( + repo_id=repo_id, + commit_sha="b" * 40, + kind="test_pass", + verified_at="2026-06-25T10:00:00+00:00", + actor="ci-bot", + source="warpline", + ) + assert len(store.list_verification_events(tmp_path)) == 1 + + +def test_list_orders_by_verified_at(tmp_path: Path) -> None: + with _open(tmp_path) as store: + repo_id = store.ensure_repo(tmp_path) + store.record_verification_event( + repo_id=repo_id, commit_sha="c" * 40, kind="test_pass", + verified_at="2026-06-25T12:00:00+00:00", actor=None, source="warpline", + ) + store.record_verification_event( + repo_id=repo_id, commit_sha="d" * 40, kind="test_pass", + verified_at="2026-06-25T09:00:00+00:00", actor=None, source="warpline", + ) + events = store.list_verification_events(tmp_path) + assert [e["commit_sha"] for e in events] == ["d" * 40, "c" * 40] + + +def test_list_orders_chronologically_across_offsets(tmp_path: Path) -> None: + # A chronologically-LATER value with a non-UTC offset must NOT sort before an + # earlier UTC value. 14:00-04:00 == 18:00Z is later than 17:00+00:00. + with _open(tmp_path) as store: + repo_id = store.ensure_repo(tmp_path) + store.record_verification_event( + repo_id=repo_id, commit_sha="e" * 40, kind="test_pass", + verified_at="2026-06-25T17:00:00+00:00", actor=None, source="warpline", + ) + store.record_verification_event( + repo_id=repo_id, commit_sha="f" * 40, kind="test_pass", + verified_at="2026-06-25T14:00:00-04:00", actor=None, source="warpline", + ) + events = store.list_verification_events(tmp_path) + # UTC 17:00 (e) is earlier than UTC 18:00 (f) -> e first. + assert [ev["commit_sha"] for ev in events] == ["e" * 40, "f" * 40] + + +def test_list_change_events_for_key_ids_filters(tmp_path: Path) -> None: + with _open(tmp_path) as store: + repo_id = store.ensure_repo(tmp_path) + k1 = store.ensure_entity_key(repo_id, "python:function:m.py::f", None, "1" * 40) + k2 = store.ensure_entity_key(repo_id, "python:function:m.py::g", None, "2" * 40) + for kid, sha in ((k1, "1" * 40), (k2, "2" * 40)): + store.append_change_event( + repo_id=repo_id, entity_key_id=kid, commit_sha=sha, path="m.py", + change_kind="modified", actor="dev", changed_at="2026-06-25T08:00:00+00:00", + ) + only_k1 = store.list_change_events_for_key_ids(tmp_path, [k1]) + assert {r["entity_key_id"] for r in only_k1} == {k1} + assert store.list_change_events_for_key_ids(tmp_path, []) == [] + + +def test_list_change_events_for_key_ids_is_oldest_first(tmp_path: Path) -> None: + # Ordering is load-bearing: compose_verification_freshness treats + # entity_change_commits[-1] as the LATEST change. A wrong ORDER BY would make + # the OLDEST change the "latest" and silently report stale-as-fresh. + with _open(tmp_path) as store: + repo_id = store.ensure_repo(tmp_path) + k = store.ensure_entity_key(repo_id, "python:function:m.py::f", None, "1" * 40) + store.append_change_event( + repo_id=repo_id, entity_key_id=k, commit_sha="1" * 40, path="m.py", + change_kind="modified", actor="dev", changed_at="2026-06-25T08:00:00+00:00", + ) + store.append_change_event( + repo_id=repo_id, entity_key_id=k, commit_sha="2" * 40, path="n.py", + change_kind="modified", actor="dev", changed_at="2026-06-25T20:00:00+00:00", + ) + rows = store.list_change_events_for_key_ids(tmp_path, [k]) + assert [r["commit_sha"] for r in rows] == ["1" * 40, "2" * 40] # oldest-first + + +def test_list_change_events_for_key_ids_deduplicates_input(tmp_path: Path) -> None: + # Regression: building the IN-clause from raw key_ids but binding deduped + # unique_ids raises sqlite3.ProgrammingError (wrong binding count) whenever + # the caller passes duplicates. This ensures the fix is correct. + with _open(tmp_path) as store: + repo_id = store.ensure_repo(tmp_path) + k = store.ensure_entity_key(repo_id, "python:function:m.py::f", None, "1" * 40) + other = store.ensure_entity_key(repo_id, "python:function:m.py::g", None, "2" * 40) + for kid, sha in ((k, "1" * 40), (other, "2" * 40)): + store.append_change_event( + repo_id=repo_id, entity_key_id=kid, commit_sha=sha, path="m.py", + change_kind="modified", actor="dev", changed_at="2026-06-25T08:00:00+00:00", + ) + # Pass k twice (duplicate) — must not raise ProgrammingError. + rows = store.list_change_events_for_key_ids(tmp_path, [k, k, other]) + assert rows # returns results + assert {r["entity_key_id"] for r in rows} == {k, other} diff --git a/tests/test_verify_record.py b/tests/test_verify_record.py new file mode 100644 index 0000000..c427a36 --- /dev/null +++ b/tests/test_verify_record.py @@ -0,0 +1,163 @@ +from __future__ import annotations + +import subprocess +from pathlib import Path + +import pytest + +from warpline import commands +from warpline.errors import WarplineError +from warpline.store import WarplineStore, default_store_path + + +def _git_repo(tmp_path: Path) -> tuple[Path, str]: + repo = tmp_path / "r" + repo.mkdir() + for args in ( + ["init", "-q"], + ["config", "user.email", "t@t"], + ["config", "user.name", "t"], + ): + subprocess.run(["git", *args], cwd=repo, check=True, capture_output=True) + (repo / "f.txt").write_text("hello\n") + subprocess.run(["git", "add", "."], cwd=repo, check=True, capture_output=True) + subprocess.run(["git", "commit", "-q", "-m", "c0"], cwd=repo, check=True, capture_output=True) + sha = subprocess.run( + ["git", "rev-parse", "HEAD"], cwd=repo, check=True, text=True, capture_output=True + ).stdout.strip() + return repo, sha + + +def test_mcp_module_imports() -> None: + # mcp.py runs assert_inputschema_consumed() + a strict zip at IMPORT time; a + # missing consume-declaration or handler crashes here. Keep this first so an + # import crash is distinguishable from a metadata-assertion failure below. + from warpline import mcp + + assert mcp.TOOL_SPECS + + +def test_verify_record_stores_resolved_sha(tmp_path: Path) -> None: + repo, sha = _git_repo(tmp_path) + env = commands.verify_record( + repo, commit="HEAD", kind="test_pass", actor="ci", now="2026-06-25T10:00:00+00:00" + ) + assert env["ok"] is True + assert env["schema"] == "warpline.verification_record.v1" + # The SYMBOLIC ref HEAD must be stored as the resolved 40-hex object SHA. + assert env["data"]["commit_sha"] == sha + assert env["data"]["kind"] == "test_pass" + assert env["data"]["actor"] == "ci" + assert env["data"]["source"] == "warpline" + with WarplineStore.open(default_store_path(repo)) as store: + events = store.list_verification_events(repo) + assert len(events) == 1 + assert events[0]["commit_sha"] == sha + + +def test_verify_record_envelope_is_local_only(tmp_path: Path) -> None: + repo, _ = _git_repo(tmp_path) + env = commands.verify_record(repo, commit="HEAD", kind="test_pass") + assert env["meta"]["local_only"] is True + assert env["meta"]["peer_side_effects"] == [] + + +def test_verify_record_is_idempotent(tmp_path: Path) -> None: + repo, _ = _git_repo(tmp_path) + commands.verify_record( + repo, commit="HEAD", kind="test_pass", now="2026-06-25T10:00:00+00:00" + ) + env2 = commands.verify_record( + repo, commit="HEAD", kind="test_pass", now="2026-06-25T10:00:00+00:00" + ) + assert env2["data"]["idempotency"] == "already_recorded" + with WarplineStore.open(default_store_path(repo)) as store: + assert len(store.list_verification_events(repo)) == 1 + + +def test_verify_record_idempotent_across_different_timestamps(tmp_path: Path) -> None: + # verified_at is NOT part of UNIQUE(repo_id, commit_sha, kind, source), so a + # re-record at a DIFFERENT time must still collapse to a single row. + repo, _ = _git_repo(tmp_path) + commands.verify_record(repo, commit="HEAD", kind="test_pass", now="2026-06-25T10:00:00+00:00") + commands.verify_record(repo, commit="HEAD", kind="test_pass", now="2026-06-25T23:00:00+00:00") + with WarplineStore.open(default_store_path(repo)) as store: + assert len(store.list_verification_events(repo)) == 1 + + +def test_verify_record_in_detached_head(tmp_path: Path) -> None: + # CI commonly runs on a detached HEAD. verify_record(commit="HEAD") must still + # resolve HEAD to the detached commit's object SHA and store that. + repo, sha = _git_repo(tmp_path) + subprocess.run(["git", "checkout", "-q", sha], cwd=repo, check=True, capture_output=True) + env = commands.verify_record( + repo, commit="HEAD", kind="ci_pass", now="2026-06-25T10:00:00+00:00" + ) + assert env["data"]["commit_sha"] == sha + + +def test_cli_verify_record_bad_commit_does_not_exit_zero(tmp_path: Path) -> None: + # Mirror tests/test_cli_dispatch.py's invocation style (read it first). A bad + # --commit must not return success. If cli.main has a top-level WarplineError + # handler producing an ok:false envelope + nonzero return, assert that; + # otherwise assert the non-zero/raised outcome the existing verbs produce. + from warpline import cli + + repo, _ = _git_repo(tmp_path) + try: + rc = cli.main( + ["verify-record", "--repo", str(repo), "--commit", "no-such-ref", + "--kind", "test_pass", "--json"] + ) + except Exception: + return # surfaced as an exception (traceback) -> not a success path + assert rc != 0 + + +def test_verify_record_bad_ref_raises_structured_error(tmp_path: Path) -> None: + repo, _ = _git_repo(tmp_path) + with pytest.raises(WarplineError) as exc: + commands.verify_record(repo, commit="no-such-ref", kind="test_pass") + data = exc.value.to_error_data() + assert data["error_code"] == "invalid_rev_range" + assert data["rejected_field"] == "commit" + # No row written. + with WarplineStore.open(default_store_path(repo)) as store: + assert store.list_verification_events(repo) == [] + + +def test_verify_record_empty_kind_raises_structured_error(tmp_path: Path) -> None: + repo, _ = _git_repo(tmp_path) + with pytest.raises(WarplineError) as exc: + commands.verify_record(repo, commit="HEAD", kind=" ") + data = exc.value.to_error_data() + assert data["error_code"] == "missing_required_field" + assert data["rejected_field"] == "kind" + + +def test_verify_record_empty_commit_raises_missing_required_field(tmp_path: Path) -> None: + # An absent/empty commit must raise MissingRequiredFieldError (missing_required_field), + # NOT BadRevisionError (invalid_rev_range) — symmetric with the blank-kind guard. + repo, _ = _git_repo(tmp_path) + with pytest.raises(WarplineError) as exc: + commands.verify_record(repo, commit="", kind="test_pass") + data = exc.value.to_error_data() + assert data["error_code"] == "missing_required_field" + assert data["rejected_field"] == "commit" + + +def test_mcp_lists_verification_record_tool_with_mutating_metadata() -> None: + from warpline import mcp + + names = {spec["endorsed"] for spec in mcp.TOOL_SPECS} + assert "warpline_verification_record" in names + spec = next(s for s in mcp.TOOL_SPECS if s["endorsed"] == "warpline_verification_record") + meta = spec["metadata"] + assert meta["read_only"] is False + assert meta["writes_local_state"] is True + assert meta["mutates_paths"] == [".weft/warpline/"] + assert meta["local_only"] is True + assert meta["peer_side_effects"] == [] + # Both endorsed + shim dispatch to a handler. + assert "warpline_verification_record" in mcp._HANDLERS + assert "verify_record" in mcp._HANDLERS diff --git a/uv.lock b/uv.lock index 1b365ab..ddf5a11 100644 --- a/uv.lock +++ b/uv.lock @@ -46,6 +46,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/45/19/cc8bd127d28a43da249aa955cfd164cf8fd534e79e42cea96c4854d72fd0/ast_serialize-0.5.0-cp39-abi3-win_arm64.whl", hash = "sha256:92a31c9c20d25a076edaeec76b128a3535d74a24f340b9a8a7e96c9b86dc9642", size = 1081181, upload-time = "2026-05-17T17:48:28.122Z" }, ] +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -64,6 +73,33 @@ 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 = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } +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]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + [[package]] name = "librt" version = "0.11.0" @@ -229,6 +265,130 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, ] +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +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 = "rpds-py" +version = "2026.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2e/43/25a8dcd3feedd735039a8f0b5b7e3b118232b5eae288c4fd9ab200d41094/rpds_py-2026.5.1.tar.gz", hash = "sha256:07b24fea40541e28570e5b795a4a38fbdcd12550c06bd0748005ecc8116ca256", size = 64459, upload-time = "2026-05-28T12:02:13.232Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/e7/a78582dc57caa592dcc7d4fb69b61390561e908eb3d2f5df5928a8e354c0/rpds_py-2026.5.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3abe24a66e57adcfa645d718063a5fa5103ecc71ddbf26d78af8f9368018ff1d", size = 353040, upload-time = "2026-05-28T11:59:12.531Z" }, + { url = "https://files.pythonhosted.org/packages/a3/43/35e3f136343aef451e545ce8c38d36c2f93c0ed88703db8b64ba2b205c68/rpds_py-2026.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:58b1d94308ddf0b1982f61f2eb54bf92997c9ece8a8093ef014250f4a517906c", size = 345775, upload-time = "2026-05-28T11:59:13.827Z" }, + { url = "https://files.pythonhosted.org/packages/20/e1/0f2160c5982d3157734d5cb3ed63d8b2d583a73c9864f77b666449f32cf8/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fa92420128dadce7f54bd73ba1825a273e9268fe9e35dbf7e6362890efa4e08", size = 376329, upload-time = "2026-05-28T11:59:15.271Z" }, + { url = "https://files.pythonhosted.org/packages/d0/11/ee0ba42aff83bf4effdbc576673c6be64c5e173978c3f6d537e94482f77d/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ca653c6546386227cd9800d1bef6a348099acf8db4250341da6d90f663d6dfcb", size = 383539, upload-time = "2026-05-28T11:59:16.665Z" }, + { url = "https://files.pythonhosted.org/packages/11/df/d94aa6a499d4ac40afe2d7620f2c597fd3c0f182e854ad7cf3f596a81cb6/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:66c93681c4729e4e3ecba31b8179fae083ff3118841672835140338b4b9867c1", size = 494674, upload-time = "2026-05-28T11:59:17.991Z" }, + { url = "https://files.pythonhosted.org/packages/1f/75/33d30f43bb2f458de11979486a591b1bf6e5651765ed1704c6197c2dc773/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:40ff257542e04796880e011e15cd4dc21c2599975df2aaa8f2c8495ca574e1a5", size = 389268, upload-time = "2026-05-28T11:59:19.434Z" }, + { url = "https://files.pythonhosted.org/packages/f4/1e/2c9096fc19d5fd084b0184ca2b651e659aa0a37e6fdbecf6ece47f147fe1/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b6825cc329b290e93c5f6a9be2393118a763f6ccf6abd83704e0c102ca583644", size = 376280, upload-time = "2026-05-28T11:59:21Z" }, + { url = "https://files.pythonhosted.org/packages/b9/e5/61ec9f8be8211ea7f48448195549e4aaf02004083475493b0e137702ecb2/rpds_py-2026.5.1-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:de42116e69cb53b911cc34aee5ab98f36c597b822545045d49e938818b99e5e4", size = 387233, upload-time = "2026-05-28T11:59:22.454Z" }, + { url = "https://files.pythonhosted.org/packages/0d/ca/bcec1005c4f4a234f92a29078631fee49206c7265ccae966f18fd332e80e/rpds_py-2026.5.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c0f920015df2a504bebaba6d4c31ccf3fcf942f92655c086da30b671aad19aa6", size = 405009, upload-time = "2026-05-28T11:59:23.845Z" }, + { url = "https://files.pythonhosted.org/packages/72/e6/4d5718c5cf26c522dc7c9999e238da1e77380b81d0c5d1df11e271ddfeb1/rpds_py-2026.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0408a24e44feb919423dc6d9da677cb5cddb894d2ca9e763967d156d9c60fab4", size = 553113, upload-time = "2026-05-28T11:59:25.184Z" }, + { url = "https://files.pythonhosted.org/packages/d4/25/2ee807bdb3e1f0b7eddf7782acd5665a8b5205a331a7d7244a52c4812fd9/rpds_py-2026.5.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:cea68bcd53467561ae2f96a6bdad1544299ba97b5b0ddcd5ac3d376e5c781c24", size = 618838, upload-time = "2026-05-28T11:59:26.749Z" }, + { url = "https://files.pythonhosted.org/packages/6a/c1/7d4c26f167f8c41501cc073d30ee22082b16ce358cf5b00ec97cbc7804ea/rpds_py-2026.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4be8b1d2a705cc37d08256004e1d07de143fa0075c8e85a3df020b776f62b732", size = 582436, upload-time = "2026-05-28T11:59:28.11Z" }, + { url = "https://files.pythonhosted.org/packages/04/1d/9d12b0a337bab46f4769f8857f4007e3b2d639e14f9a44a0efe157696e64/rpds_py-2026.5.1-cp312-cp312-win32.whl", hash = "sha256:6736718bd4fc49cbcb538ba30516fdbef161522acefb739657d48b97bd864fed", size = 212734, upload-time = "2026-05-28T11:59:29.689Z" }, + { url = "https://files.pythonhosted.org/packages/c5/93/e4116f2de7f56bc7406a76033dc501811ddeb22b7f056b92d632871ebb0c/rpds_py-2026.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:0a7d1eec967df0e9b22614a5e177622e0c89611d03727fa0cb48e45028907870", size = 229045, upload-time = "2026-05-28T11:59:31.033Z" }, + { url = "https://files.pythonhosted.org/packages/cb/53/6c3419d85eb2ec5938a37627c585b42d76a63bb731d6e42ed4b079ebf486/rpds_py-2026.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:1841d067089e117142d79b98aa0df2f08b52f2ecc1819dd2700636c0db74a473", size = 223967, upload-time = "2026-05-28T11:59:32.318Z" }, + { url = "https://files.pythonhosted.org/packages/6c/32/14c961ad295f490eb0849ada8b79683e93a59b9de3afdd983eaf55fa6867/rpds_py-2026.5.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:efef4ac29c6ff495531eb17ee705b62841ecaa291b7c7077e848ea03e237164d", size = 352787, upload-time = "2026-05-28T11:59:33.655Z" }, + { url = "https://files.pythonhosted.org/packages/ca/bb/d1b85117967c11191441a7274ae616c65d93901d082c588f89a50a8da5ae/rpds_py-2026.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c39f5b67a8a2e67179ada2a954227d670fe65fa9098457f698f56ddf248709b3", size = 345179, upload-time = "2026-05-28T11:59:35Z" }, + { url = "https://files.pythonhosted.org/packages/7c/46/d84105f062e626a1b233f863907288a4708c2d833b8b4c6fb2764bc080c0/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b5c30f3f04eef4fbd362226a6f31d7c8895ca4fbb6e0b790f6890a98d8da8559", size = 376173, upload-time = "2026-05-28T11:59:36.43Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ae/469d7959ce5b1201e1de135dc735b86db3b35dd0d1734f6a44246d5f061c/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:277f6c82f0580848796c7ecc8a7173aa3bfb928e4ff831261c2f60a81dc270db", size = 383162, upload-time = "2026-05-28T11:59:37.995Z" }, + { url = "https://files.pythonhosted.org/packages/dc/a2/57853d31a1116a561aa072794602ad3f6341e18d70a8523f1bd5b9fc1e5a/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:63c2c4c213f1a4e3f3de28ecab029dbdee976324e729c0d7a55211be72576b02", size = 495093, upload-time = "2026-05-28T11:59:39.453Z" }, + { url = "https://files.pythonhosted.org/packages/99/63/3a8eabcad9314b7daf5c65f451d2c33d989235cd8a5762186cf2c3f5a4f8/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3350ec808fb538fe71a1f94dfaa0e29c598dfad805ce49f0caec5ae3183c652b", size = 389829, upload-time = "2026-05-28T11:59:40.896Z" }, + { url = "https://files.pythonhosted.org/packages/4b/25/05678d97fc25e2622df14dc530fb82023174ecfff6733991ed0d78f167bd/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b1b964e3ab599e718dc46c018d104b1ebc007cbc6567d827c94a687fca56d77e", size = 374786, upload-time = "2026-05-28T11:59:42.626Z" }, + { url = "https://files.pythonhosted.org/packages/88/d1/8c90b6431e80a3b91b284a5c7c8c0c4f9c006444d90477a740d6e0f9c694/rpds_py-2026.5.1-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:19cb09fab7b7fc96b2a6e28f2e34b72a3705ff27b37edb77455316e5d3f3dc9b", size = 386920, upload-time = "2026-05-28T11:59:44.124Z" }, + { url = "https://files.pythonhosted.org/packages/ff/99/4638f672ab356682d633ee0da9255f5b67ce6efd0b85eb94ad3e255e65a5/rpds_py-2026.5.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:abe76bcdba31e576cb83eeb8797aa0d882b738fef6dc65d0601fc753806a5b46", size = 405059, upload-time = "2026-05-28T11:59:47.177Z" }, + { url = "https://files.pythonhosted.org/packages/66/3f/3546524b6eb4cc2e1f363a3d638fa52f6c24faae3500c25fb488b02f1740/rpds_py-2026.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8bff7073db3899158fff55ebf57b113a67030af26f80a18978f9f0aa60250ddf", size = 553030, upload-time = "2026-05-28T11:59:48.603Z" }, + { url = "https://files.pythonhosted.org/packages/c6/c3/7b3388c796fcf471bd17194242d4dc1a7608567c0fa422bcc1c5e79f9c1e/rpds_py-2026.5.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8ba264fa49be666cd9cc56bf34ec7002fb3d27a4aee5bcb4d43d0d18feb1bb6f", size = 618975, upload-time = "2026-05-28T11:59:50.314Z" }, + { url = "https://files.pythonhosted.org/packages/61/1e/a3cb07f2795075d1d88efddae2f541359fde5f08c81ee114c29c2949c90a/rpds_py-2026.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4860b603ddda0475a8885499b3729e90229d480105b42651962a5397d995fa89", size = 581178, upload-time = "2026-05-28T11:59:51.673Z" }, + { url = "https://files.pythonhosted.org/packages/a1/74/e758c03a5ef46f04c37f2651a2893db846d569ba8a7bca469d4b58939bcd/rpds_py-2026.5.1-cp313-cp313-win32.whl", hash = "sha256:7944270ae71383f6e2657dd7d5ce4eeb4ac2d0059a6738f0510583d462ab4842", size = 212481, upload-time = "2026-05-28T11:59:53.148Z" }, + { url = "https://files.pythonhosted.org/packages/70/ec/a2aca432db9c7359b40fa393eeeaa0d166c2f70175be956e75fa24197c44/rpds_py-2026.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:88647f43a73c4e01be19b04ceef0c8d3a1958153604d13c773becd8016f2a0cf", size = 228519, upload-time = "2026-05-28T11:59:54.505Z" }, + { url = "https://files.pythonhosted.org/packages/29/60/a73bfdd45b096574556acf303bbd9fa9eed36ca8a818b514e2a5d5fe2b9d/rpds_py-2026.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:453895624ecf7db7063b1004e44037522bbaef9ff6a945e59bc71662d7a03abd", size = 223446, upload-time = "2026-05-28T11:59:56.081Z" }, + { url = "https://files.pythonhosted.org/packages/18/e2/408105fd611823f00882aea810f3989a30d26b1bab8b6beb20f98c724e0e/rpds_py-2026.5.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:b4e4bc98639ec915f512fde3aa7a95e0041d95d9c3cc86eea841fa63cb1e8600", size = 355287, upload-time = "2026-05-28T11:59:57.448Z" }, + { url = "https://files.pythonhosted.org/packages/8d/58/5c4a43436843c90d0f6d19f82c200c80e3843ca9fa07b237623327f6d384/rpds_py-2026.5.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cacedb7a6e167680acba45ad5716e89067d225dc80da0d7040cae8c81d4572fa", size = 347033, upload-time = "2026-05-28T11:59:58.881Z" }, + { url = "https://files.pythonhosted.org/packages/fb/c2/1a71acdacaf4e259b10278fb87b039ded3cf80041bcd89dd8a3ea702ded6/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:68700371c5d7ae1412862ddfa719090925c93ecf351c566d66f09d04b136ea00", size = 376891, upload-time = "2026-05-28T12:00:00.516Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c8/535f3d9b65addd8e28aa87b83c6e526799c3717a88273db8ea795beeef7a/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:296c799becfa849c779c8725494fe9ed94959ed886787df4364b058465bad7f0", size = 385646, upload-time = "2026-05-28T12:00:02.394Z" }, + { url = "https://files.pythonhosted.org/packages/1c/91/dc033f313345c354ade914dbe73cdb90b615a4409ea02430d5356794f3d8/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d3858b908218ee108d0bbfb2095ccc237648053c9bf98affad7cb079acaf1d97", size = 498830, upload-time = "2026-05-28T12:00:04.189Z" }, + { url = "https://files.pythonhosted.org/packages/27/fc/90fcbea459dbb8ddc18a2e0fd1de9412b48bc84ffff2db771cf714bacfd6/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4fb8d2e7cb2f850b169806d61d1b991738acec96500a75c30f49caf064ce7cef", size = 392830, upload-time = "2026-05-28T12:00:05.797Z" }, + { url = "https://files.pythonhosted.org/packages/b2/1d/46cd11a228c9750684a798d98f878be6f614aa762438da7378f035e79e35/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:27b74c10ed6a8f190f4287f53bcfea348b92a84a9c9f70d30183d1e6172d580d", size = 379613, upload-time = "2026-05-28T12:00:07.433Z" }, + { url = "https://files.pythonhosted.org/packages/24/4a/d9b0c6af3a1de03eb93741bbe8be2bdce84d8fda8224f3005451d86df389/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:b9a6528956191c48c52294a592dbd4a8386d7048bdb25c0efcb6b966466c6d83", size = 388183, upload-time = "2026-05-28T12:00:09.227Z" }, + { url = "https://files.pythonhosted.org/packages/c5/b4/db7aaabdda6d020afc87d981bcc2f57a434c7dec60ecfc2ab3dd50b20351/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:af03e34e860047bc7a352b842856fcf78798fbb81132cc98bd2f907ab4eb9cd2", size = 408578, upload-time = "2026-05-28T12:00:10.779Z" }, + { url = "https://files.pythonhosted.org/packages/08/d6/070f6a41cbb343e2ac4171859bf3f3623e0ab002f72619d6d505313ec2de/rpds_py-2026.5.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:fea6e836d10abbe191d557d33bd58bd5987725fe63aa1eefe557d230209855bd", size = 553573, upload-time = "2026-05-28T12:00:12.443Z" }, + { url = "https://files.pythonhosted.org/packages/75/ab/1a71ea3589c4345dac0a0518f0e6a031cb42689277851b683c46d27463a5/rpds_py-2026.5.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:fc0c0f878ea770a0a8a462456c5ad36fc9fe6358e6b76fdadc7f17575e0b8bf1", size = 620861, upload-time = "2026-05-28T12:00:14.09Z" }, + { url = "https://files.pythonhosted.org/packages/8a/22/9bf80a56069c0c443fcfefac639a86a744550a2898817a6dfd3e26654924/rpds_py-2026.5.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:e0b360f316d966b048b085857630b3cc51f3db2f07b06f440eac8f695374d1e3", size = 585633, upload-time = "2026-05-28T12:00:15.66Z" }, + { url = "https://files.pythonhosted.org/packages/da/68/3b2c0a75c9e04125696f84ebdbbf304acf5a40b58ba4481cdb98a922c3ba/rpds_py-2026.5.1-cp313-cp313t-win32.whl", hash = "sha256:a2999883eedf72fdfb7520b92c7d4ec2572a71ff40239377aa604cc529eecafc", size = 210074, upload-time = "2026-05-28T12:00:17.291Z" }, + { url = "https://files.pythonhosted.org/packages/e7/8b/609157d5a25d37d4f29f92840ba531f416907c34ae5c5739dd21fc2bef98/rpds_py-2026.5.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e07be2a9d7122bd6e82dea89814ef8dc893feb1aae97fec1630f3263bbb30e55", size = 228635, upload-time = "2026-05-28T12:00:18.73Z" }, + { url = "https://files.pythonhosted.org/packages/d4/6f/19c1918a4b590d8de87e712e4abe4b3875771eff60216fb6153cf6665c68/rpds_py-2026.5.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:1f2c391c3059798093b65df23aca2cac150460ae9c630d99dec83d703d9485b9", size = 349756, upload-time = "2026-05-28T12:00:20.217Z" }, + { url = "https://files.pythonhosted.org/packages/e5/60/a06fe7da34eca79dacbf958a2ba0c6eea85bc2b29de20080bf40f72f66fa/rpds_py-2026.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:413b424f7c4ee65ab5e5be91f5731be0f8b41a1ee2b12dfe810d716312e95a78", size = 343831, upload-time = "2026-05-28T12:00:21.711Z" }, + { url = "https://files.pythonhosted.org/packages/bf/ec/b2333b97b90e2a6ef6ca8ad386ee284968e74bcfe113b3f1a8d9036429a9/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2c595a1d9255dce0599e13130d1440ab2506654f2b50294226ee06402f8fef63", size = 375127, upload-time = "2026-05-28T12:00:23.326Z" }, + { url = "https://files.pythonhosted.org/packages/14/7f/e00aae54067f2b488c4637961d5f58204d470795fc791085fa3f15060d2e/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1c27c5f6102eac8c03e7595a00827a53b271ba40a53b59ff8709170e0855ea4a", size = 379034, upload-time = "2026-05-28T12:00:24.89Z" }, + { url = "https://files.pythonhosted.org/packages/be/cc/423999bbb8ae8dc93c77fc1d5e984ade5eb89d237d3bb884ccfa72ae2890/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6c7fcf61d44cacecaf3aea542b0e053db77972a4573e7ceda16fb2b399161195", size = 490823, upload-time = "2026-05-28T12:00:26.676Z" }, + { url = "https://files.pythonhosted.org/packages/0f/aa/c671bf660f12e68d3c52ff86c7066ed1372df5a0f4f2ff584e419b8207e7/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2c817a189d4ee14290420e5ff051e4dd6baa13f3edf84685071dee07a6d538ee", size = 388144, upload-time = "2026-05-28T12:00:28.577Z" }, + { url = "https://files.pythonhosted.org/packages/19/c8/d63bb75b68afe77b229e3021c6031bcaf01da5db5b0e69d0d10f9ba679a7/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21846aac0ed2e0589f38c12dc44e77bb64e494b771eadbcf169cba00566ba7ba", size = 371959, upload-time = "2026-05-28T12:00:30.304Z" }, + { url = "https://files.pythonhosted.org/packages/82/35/c51122014d8274ff37dc606d60049c3db7d83da02b5b282511e5a906a9a6/rpds_py-2026.5.1-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b317c87a13f769a4e787819bd508aaa5d69aa09b0880de9af6d3a8a54571cdec", size = 383558, upload-time = "2026-05-28T12:00:31.764Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f9/2790cb99c136a5363acdeacf5c27c56f3de0d4118a1f48fca83404c99c89/rpds_py-2026.5.1-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ce87129d9f2c14fa6c4a8601fb80eb4488c80d38a20cd13758ef11123e14995d", size = 402789, upload-time = "2026-05-28T12:00:33.247Z" }, + { url = "https://files.pythonhosted.org/packages/e5/1b/e4fb584f8c75d35c38150ff6a332cda949e6f97acba1f4fd123b14ab56fe/rpds_py-2026.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9cdddb6c1207d284d94fd1530adf57fbd797fe7c4b8704ba85f49414f2557e7d", size = 551405, upload-time = "2026-05-28T12:00:34.819Z" }, + { url = "https://files.pythonhosted.org/packages/d8/f7/a6731b4216cb3793ea1af5391da240f5683dacc0d13e034fe5fc3503f240/rpds_py-2026.5.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:4e237e139f94d3c036fd28eb9f564c99055476ff4ff05cd42be55ce349b5aa02", size = 616975, upload-time = "2026-05-28T12:00:36.268Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/2e051a81d95d8e63f4b35a1c463a87e8766bc3d083c067c5dfb6bf220747/rpds_py-2026.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ed0954b524873214369184a9c82b0eaa45a3fbb9a798cd95b17e0d98499e7ea0", size = 578701, upload-time = "2026-05-28T12:00:37.82Z" }, + { url = "https://files.pythonhosted.org/packages/65/56/b5f6fdb2083e32bca8a8993d89e70db114b4756c9e2c38421328126689d2/rpds_py-2026.5.1-cp314-cp314-win32.whl", hash = "sha256:2d88621d6a7d4dfa633d21abe90f280bb205274e16b1d1e61c6ad4640b2453b7", size = 209806, upload-time = "2026-05-28T12:00:39.492Z" }, + { url = "https://files.pythonhosted.org/packages/fb/80/65a5aa96c155e611d1ed844e4e1f57f3e36b021f396d9f8585d756e6b90d/rpds_py-2026.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:cef8ac28d26f4dda3533060c20fbf80a325458fa9fd23ea72a73cdfa8e978838", size = 225985, upload-time = "2026-05-28T12:00:40.94Z" }, + { url = "https://files.pythonhosted.org/packages/27/7c/ad185212e87b05f196daef92bc5f3caf07298eb47c295b5585c3dd3093ac/rpds_py-2026.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:eaaea962c68cdc68d4a533ba985ab8e9484277910bbfaa2ab3ef7732667bfed8", size = 221219, upload-time = "2026-05-28T12:00:43.15Z" }, + { url = "https://files.pythonhosted.org/packages/23/58/e14ae18759020334646b031e708ab4158d653a938822bfb7b95ef2e93aa3/rpds_py-2026.5.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:21942f52dbbd5f8758bf021213d28bd45c39e873e65e2407faf5f1846f5761ad", size = 352148, upload-time = "2026-05-28T12:00:44.638Z" }, + { url = "https://files.pythonhosted.org/packages/31/9b/5f4a1e2f960bca3ac5d052b139dd31eed97b259f9d909173821760d542e8/rpds_py-2026.5.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f414556f6e3958300ff941e40c9f97e3dc9774ddd1b3434c475d73dd354bbed3", size = 345196, upload-time = "2026-05-28T12:00:46.14Z" }, + { url = "https://files.pythonhosted.org/packages/1a/71/1d9574d6a2fa20ab60eaa55c7467f5aa20cbc770f341a05f09c0876f59e2/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ef1013a8625c74043210190b246f5b1551e09757c1f356c6e4160ef96c5bc081", size = 374981, upload-time = "2026-05-28T12:00:47.531Z" }, + { url = "https://files.pythonhosted.org/packages/0c/9a/37e99f4915a80aa71670263c1267f7ae0af95f53a3f61e6c3bdc016d4515/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cc68e231a77a5f0d774ae278a1f8e55c0456501820847c1e4efb3829f3441df6", size = 379961, upload-time = "2026-05-28T12:00:49.216Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ff/6e73f74b89d2e0715e0fc86b7dde893f9a61ae2f9b256ff3bdfe41ac4e94/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9baffb505aff33acc69b422a19f77806680f3c8632227d79f48de8a810d1c2c5", size = 495965, upload-time = "2026-05-28T12:00:51.111Z" }, + { url = "https://files.pythonhosted.org/packages/ea/e0/425faba25f59d74d4638b267f7c7a80e8649d2ef4db10a19b0c4a71e6e6f/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b8d2f912928d426e8cfa396f7f3f8d29a59e6689c86dcca3c420730c1096322b", size = 389526, upload-time = "2026-05-28T12:00:52.77Z" }, + { url = "https://files.pythonhosted.org/packages/c6/76/7a41960e3fddae47fab43a28684d5da981401dffd88253de0944148654cb/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90f628283be835db980c941767d41c9a27b5239e54ba0a9c1335247e82406964", size = 376190, upload-time = "2026-05-28T12:00:54.215Z" }, + { url = "https://files.pythonhosted.org/packages/27/60/5f38dc70824fc6951b51d35377e577a3a3a4c81a6769cc5a2de25ebe0ad1/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:1ebb2f0ab7e16132995a72de805170e0203df0c3dd22e1ef1cd1fdd90bd7a131", size = 383921, upload-time = "2026-05-28T12:00:55.673Z" }, + { url = "https://files.pythonhosted.org/packages/60/1a/d60a38caa1505f4b9483c3fbbde12c94e1079154f4f401a6da96f7e77621/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f3df3d16ded76f1f8c9cdebd0e1ea55fdf4c23b812de189814da7cf229c22a81", size = 404766, upload-time = "2026-05-28T12:00:57.518Z" }, + { url = "https://files.pythonhosted.org/packages/87/ff/602fd3f174d6425f0bce05ad0dfbec0e96b38d0f7d08a79af5aa20083885/rpds_py-2026.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9af8905b8f854990e40d5206aa5ac58d9b0fe0b7f351ff2bb086c20f6c8c6a47", size = 551343, upload-time = "2026-05-28T12:00:58.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/c1/1be13327acdbead3eca1fde03b6a34dbb011f1e864e217f0d32cc1779a7f/rpds_py-2026.5.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:036a36a87fb1cd3b214d11c4b3c4f7d2ddad933625dca1c900b56a057c07740a", size = 618502, upload-time = "2026-05-28T12:01:00.656Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d7/afb49b49d7f2be8b7ba1a9f0977fa5168003437b93086726f066544e8351/rpds_py-2026.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:62ae3853454fe9ef283a03c96c2d835d39e84b14643a9d62c82ef0fb87d702ca", size = 581916, upload-time = "2026-05-28T12:01:02.22Z" }, + { url = "https://files.pythonhosted.org/packages/25/d1/dbef8c1f8a10f07beb62b5f054e20099fd9924b3ec001b8f0b6ac7813a85/rpds_py-2026.5.1-cp314-cp314t-win32.whl", hash = "sha256:6c3d771a46ec18b12af06ce36243a9a80b07a5d0515236332d90863ca8bb326a", size = 207855, upload-time = "2026-05-28T12:01:03.821Z" }, + { url = "https://files.pythonhosted.org/packages/2a/72/bfa4e61ab8e7dc1c8adf397e05e6cbdd4239357bd72b248d3de662f23915/rpds_py-2026.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:c93c629be4636cf54337bd5f06c104d55e42ced54d681f6fe21ae510a65116f6", size = 225422, upload-time = "2026-05-28T12:01:05.194Z" }, + { url = "https://files.pythonhosted.org/packages/27/3a/7b5da92b640f67b6717ccafc83cdd06bfa7ff2395c3685c68922bb54d703/rpds_py-2026.5.1-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:3574b55c604b8f75dacb007136508bbc0db406e626301778096a133327e7f2fb", size = 349576, upload-time = "2026-05-28T12:01:06.722Z" }, + { url = "https://files.pythonhosted.org/packages/d7/8a/2aafd7ad355a1bd48ca76e2262b74b15e6432b5a1efe150efd4d779cd55d/rpds_py-2026.5.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:94068eb3ae6d43f5a786b7db96a406a34e6d5c24489feef32fd6e8946ea7b291", size = 343640, upload-time = "2026-05-28T12:01:08.441Z" }, + { url = "https://files.pythonhosted.org/packages/f7/7d/6c9523c1abbe840a1b7fba3c516d48e1d3487cc80fea4366c4071cf56784/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3a5b10e8ce894825f380a8f1b6444cf73c294dfea62afbb2d13e3a9e630cec1", size = 375322, upload-time = "2026-05-28T12:01:09.934Z" }, + { url = "https://files.pythonhosted.org/packages/5a/5d/0b7b03fb1dc509321f01de3149784ab773e34c8573022029af8076afcb9c/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fc09f82e63d4bcd58149572f857a431bae851dc747e313c3b5bdf7abb907fda8", size = 379066, upload-time = "2026-05-28T12:01:11.48Z" }, + { url = "https://files.pythonhosted.org/packages/d7/e2/8ef6012999ebf1cb1c22f876d9ce5e63d960fd4631d2af3202d3f480aa25/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e10464d17df3b582745c25cec695cb9558bca2cb6ddb631aee1787fc72c767b2", size = 494586, upload-time = "2026-05-28T12:01:13.051Z" }, + { url = "https://files.pythonhosted.org/packages/80/af/1eeb029bec67582c226b7809172207cd005073af4ebd906e65ff494f4983/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ba05adbf15d994c38ec0b7ab32e858e5110c21e9009a00a86545fd220f84e038", size = 388415, upload-time = "2026-05-28T12:01:14.631Z" }, + { url = "https://files.pythonhosted.org/packages/18/23/ffbe10711c4d766c1cab0557d6906c074f795814863c67b351355d29354a/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:77c004fdc7b891967106f78ddfd7b076bfe6813c6139c6fff6aed3bcaa960b26", size = 372427, upload-time = "2026-05-28T12:01:16.153Z" }, + { url = "https://files.pythonhosted.org/packages/bd/3a/30ba4a6ad457e5b070c18d742a33fb77d8d922b565cc881f8a5313d63bfe/rpds_py-2026.5.1-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:83bcf894486c9d78dd290d3c0124ff6dd8875d3025e2090a8ec49fcc37c55fdd", size = 383615, upload-time = "2026-05-28T12:01:17.809Z" }, + { url = "https://files.pythonhosted.org/packages/d3/69/62e242b53ce39c0814bd24e1a6e6eba6c92be716277745f317f9540a2e7b/rpds_py-2026.5.1-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c3df104083952a0e0c6f10de33e440eabe98fb6317d23e1a58c68f6df08d01b9", size = 402786, upload-time = "2026-05-28T12:01:19.419Z" }, + { url = "https://files.pythonhosted.org/packages/38/c1/a770b9c186928a1ed0f7e6d7ae50e7f3950ed23e3f9e366dbc8e38cb55de/rpds_py-2026.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:980450826cf22e133c57e0835070bdd0dd3f73b9b708c3ce223def2cb9469e14", size = 551583, upload-time = "2026-05-28T12:01:21.013Z" }, + { url = "https://files.pythonhosted.org/packages/21/7c/68e8579b95375b70d2a963103c42e705856cdb98569258bd807f4423891c/rpds_py-2026.5.1-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:205dde846f24332ab0c1188699a043b8d165b79bb84529ce272c45048ff6be01", size = 616941, upload-time = "2026-05-28T12:01:22.548Z" }, + { url = "https://files.pythonhosted.org/packages/70/a1/a6135aed5730ff03ab957182259987ac11e55fb392a28dc6f0592048a280/rpds_py-2026.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:3966b82dd563176396df030f3dd52a6e54cb69b718e95e78bd555ed3d1e0185d", size = 578349, upload-time = "2026-05-28T12:01:24.118Z" }, + { url = "https://files.pythonhosted.org/packages/09/6e/f24201a76a84e6c49d0bdfdfcb735210e21701e9b21c5bfc0ba497dd62f6/rpds_py-2026.5.1-cp315-cp315-win32.whl", hash = "sha256:7818f8d0a415be74d2be3590b0a1c1f463a642f4d0217e7d10602dceef5b79aa", size = 209922, upload-time = "2026-05-28T12:01:25.522Z" }, + { url = "https://files.pythonhosted.org/packages/9e/e4/966bc240bb0485fc265278f6de44d05834bf0b3618886e0b22e33d54c49a/rpds_py-2026.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:b3cc20c0d800af78fd0fac68086e28c1856cec51ea528bb81ea851aa40d39325", size = 226003, upload-time = "2026-05-28T12:01:27.062Z" }, + { url = "https://files.pythonhosted.org/packages/5c/5c/a15a59269cd5e74472734516c73795c15eccfc841b3d4b0228c3f53f19d0/rpds_py-2026.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:3609e9939a8a76cd904cf98a3f1f13b5dc7e150adeaee89e0ea09652ea213e16", size = 221245, upload-time = "2026-05-28T12:01:28.51Z" }, + { url = "https://files.pythonhosted.org/packages/e0/22/135ce03804e179a71ceb13be095deda4a279bc88f7a6b8fa161c5ad44e12/rpds_py-2026.5.1-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:5d333a7127d4b307601ac37792bee01bb95c867cbfacf21b6375b804d6bbd723", size = 352015, upload-time = "2026-05-28T12:01:30.214Z" }, + { url = "https://files.pythonhosted.org/packages/3b/5f/f1f6d2652eb9d848f6eb369d8db83a2da6249bb49ad2c2a48f45d54538d3/rpds_py-2026.5.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:b5f077b44a4f7808520f66dae234988d867deb9aed9be5da057ce9ba831b2a41", size = 345016, upload-time = "2026-05-28T12:01:31.656Z" }, + { url = "https://files.pythonhosted.org/packages/88/66/b74182775691ea2290c99e52ac8d5db844e56fbec90ce421f107658c8314/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55d8f9b7b78c9538fc9e04e82ec0e888ff0c3cffcfad152c77e57cd09351a98a", size = 374775, upload-time = "2026-05-28T12:01:33.136Z" }, + { url = "https://files.pythonhosted.org/packages/ff/8f/15e5a61d9f0a43902d36561d4f07cae6ae9f4716be825159fd72717f33af/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e3a8ae58895ac107ed934a6bf51e5846f95c53b9b940c2c6d310838fd5846358", size = 380270, upload-time = "2026-05-28T12:01:34.574Z" }, + { url = "https://files.pythonhosted.org/packages/02/c3/f859b12763a80540cdf2af0f15b19904cf756a71d7bdd3f82ff3e5b1bbf9/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0957cf3c2b8632ec7aaebffebea8005b353cc2a237b6e2ae3c2cac0820704cfb", size = 495285, upload-time = "2026-05-28T12:01:36.127Z" }, + { url = "https://files.pythonhosted.org/packages/1c/c7/ff27c2ac8411d30b03b1829fd88cae8dad1a4d0da48dd25e57c4038042e6/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c396c1304de421050b3681ea70f371874b54d41b0151e96109758144c231e30b", size = 389581, upload-time = "2026-05-28T12:01:37.635Z" }, + { url = "https://files.pythonhosted.org/packages/6e/67/fe92ee32a6cc05c77228a2f8b1762e7124f386ec20ff83d0757b762d58d0/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aad1bff7f666b9598e573815affd666aac6a13a585dde336f843e33350c7fadc", size = 376041, upload-time = "2026-05-28T12:01:39.307Z" }, + { url = "https://files.pythonhosted.org/packages/f8/91/b4d6685c27aba55bd82f25b278be8237038117d05f9659a6213ad3408130/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:656a042550878f12d45752452d47094b7cfe5ad1e9d7b87b5a22ad3ae5ff8015", size = 383946, upload-time = "2026-05-28T12:01:41.043Z" }, + { url = "https://files.pythonhosted.org/packages/bd/79/2c1d832a53c8e0f8e98fc970ec257b950fecd4f62be2ab7182b500a0cbc8/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:73c4bd4f70294737b5206a3e8e30ccadbf8a60301831c8ea23eec5dbeea1ecfa", size = 405526, upload-time = "2026-05-28T12:01:43.032Z" }, + { url = "https://files.pythonhosted.org/packages/78/c4/c98117b03c6a8581ab2c2dfccfe9a5ad82bd8128a3c28b46a6ad2d97c393/rpds_py-2026.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:43bca78665423cabae77146f2fe7ce55272b6c8d55d82cca83effd42c7e13972", size = 551165, upload-time = "2026-05-28T12:01:44.648Z" }, + { url = "https://files.pythonhosted.org/packages/3b/c1/bc479ca069200af730881b1bd525e3114b2b391a351509fcb1b772f28086/rpds_py-2026.5.1-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:42d0f20e85e549c870749d0e247f0c10d318a45b7e9676d575d2dcb04a1b2e66", size = 618778, upload-time = "2026-05-28T12:01:46.337Z" }, + { url = "https://files.pythonhosted.org/packages/77/65/38ab2f90df44c2febfb63cc10ced40763d9b4bc94d173e734528663fe7f5/rpds_py-2026.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:b1be5c35683684d5331b93600c210e8367c254683d8a6df6bd21bd2da3a334fb", size = 581839, upload-time = "2026-05-28T12:01:48.109Z" }, + { url = "https://files.pythonhosted.org/packages/15/2d/ce1f605fe036aadd460e5822e578c6c7ec3a860936cca37d6e0f299daa77/rpds_py-2026.5.1-cp315-cp315t-win32.whl", hash = "sha256:75808f6c38ce7749bb68cc2770161aae5045e6c6f6781a9782e74b93304399df", size = 207866, upload-time = "2026-05-28T12:01:49.648Z" }, + { url = "https://files.pythonhosted.org/packages/79/cb/966040123eb102371559746908ef2c9471f4d43e17ec9a645a2258dab64b/rpds_py-2026.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:90bd6630002a1c7f09e7843dd79f0d24f3d2897cc25a753480917865d14f15b3", size = 225441, upload-time = "2026-05-28T12:01:51.408Z" }, +] + [[package]] name = "ruff" version = "0.15.17" @@ -270,6 +430,7 @@ source = { editable = "." } [package.dev-dependencies] dev = [ + { name = "jsonschema" }, { name = "mypy" }, { name = "pytest" }, { name = "ruff" }, @@ -279,6 +440,7 @@ dev = [ [package.metadata.requires-dev] dev = [ + { name = "jsonschema", specifier = ">=4.26.0" }, { name = "mypy", specifier = ">=1.18.2" }, { name = "pytest", specifier = ">=9.0.1" }, { name = "ruff", specifier = ">=0.14.8" },