From 2621cf2752a453ed79af37b583185a0291795bcf Mon Sep 17 00:00:00 2001 From: tegwick Date: Tue, 8 Sep 2026 12:10:17 +0200 Subject: [PATCH] Use explicit fresh hosted capability snapshots for plan checks Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a07ff8-19d0-7820-b4d0-1353833cb7fc --- reuse_surface/cli.py | 30 +++-- reuse_surface/plan_check.py | 20 ++- reuse_surface/plan_snapshot.py | 109 ++++++++++++++++ tests/test_hosted_plan_check.py | 126 +++++++++++++++++++ tools/README.md | 25 +++- workplans/REUSE-WP-0022-hosted-plan-check.md | 54 ++++++++ 6 files changed, 353 insertions(+), 11 deletions(-) create mode 100644 reuse_surface/plan_snapshot.py create mode 100644 tests/test_hosted_plan_check.py create mode 100644 workplans/REUSE-WP-0022-hosted-plan-check.md diff --git a/reuse_surface/cli.py b/reuse_surface/cli.py index c3af4aa..39ba2de 100644 --- a/reuse_surface/cli.py +++ b/reuse_surface/cli.py @@ -22,6 +22,7 @@ from reuse_surface.hub_sync import ( ) from reuse_surface.overlaps import find_overlaps from reuse_surface.statehub_bridge import list_open_capability_requests +from reuse_surface.plan_snapshot import SnapshotUnavailable from reuse_surface.plan_check import ( format_plan_check_json, format_plan_check_markdown, @@ -661,13 +662,19 @@ def cmd_plan_check(args: argparse.Namespace) -> int: print("error: provide a workplan file or --intent", file=sys.stderr) return 1 - result = run_plan_check( - query, - reuse_threshold=args.reuse_threshold, - extend_threshold=args.extend_threshold, - use_llm=not args.no_llm, - llm_url=args.llm_url, - ) + try: + result = run_plan_check( + query, + reuse_threshold=args.reuse_threshold, + extend_threshold=args.extend_threshold, + use_llm=not args.no_llm, + llm_url=args.llm_url, + hub_url=args.hub_url, + max_index_age_hours=args.max_index_age_hours, + ) + except SnapshotUnavailable as exc: + print(f"error: {exc}", file=sys.stderr) + return 2 recorded = None if args.record_outcome: @@ -941,6 +948,13 @@ def main(argv: list[str] | None = None) -> int: "workplan", nargs="?", help="path to a workplan file (frontmatter + body)" ) plan_check.add_argument("--intent", help="free-text intent instead of a workplan file") + plan_check.add_argument( + "--hub-url", help="read the hosted /v1/federated index; fail on unavailable, stale or warned composition", + ) + plan_check.add_argument( + "--max-index-age-hours", type=float, default=24, + help="maximum hosted composition age in hours (default: 24); requires --hub-url", + ) plan_check.add_argument( "--format", choices=["markdown", "json"], default="markdown" ) @@ -1210,4 +1224,4 @@ def main(argv: list[str] | None = None) -> int: if __name__ == "__main__": - raise SystemExit(main()) \ No newline at end of file + raise SystemExit(main()) diff --git a/reuse_surface/plan_check.py b/reuse_surface/plan_check.py index e00d6b3..9118bba 100644 --- a/reuse_surface/plan_check.py +++ b/reuse_surface/plan_check.py @@ -16,6 +16,7 @@ from reuse_surface import hub_client from reuse_surface.federation import FEDERATED_INDEX_PATH from reuse_surface.llm_bridge import execute_prompt, extract_json_object from reuse_surface.overlaps import TOKEN_RE +from reuse_surface.plan_snapshot import load_hosted_snapshot from reuse_surface.registry import ROOT, load_index from reuse_surface.statehub_bridge import file_capability_request @@ -260,8 +261,16 @@ def run_plan_check( top_n: int = 5, use_llm: bool = True, llm_url: str | None = None, + hub_url: str | None = None, + max_index_age_hours: float = 24, ) -> dict[str, Any]: - capabilities, updated = load_federated_capabilities() + if hub_url is not None: + capabilities, updated, provenance = load_hosted_snapshot( + hub_url, max_age_hours=max_index_age_hours + ) + else: + capabilities, updated = load_federated_capabilities() + provenance = {"kind": "local", "capability_count": len(capabilities)} matches = match_query(query, capabilities, tie_window=tie_window) top_score = matches[0].score if matches else 0.0 verdict = verdict_for_score( @@ -302,6 +311,7 @@ def run_plan_check( ], "federated_index_updated": updated, "federated_index_stale_warning": _staleness_warning(updated), + "index_source": provenance, } if notes: result["notes"] = notes @@ -334,7 +344,13 @@ def format_plan_check_markdown(result: dict[str, Any]) -> str: elif verdict == "extend": lines.append("**Verdict: EXTEND** — scope overlaps closely enough that extending the top match is likely cheaper than a new capability.") else: - lines.append("**Verdict: NEW** — no close match in the federated index; proceed.") + lines.append("**Verdict: NEW** — no close lexical match in this index; review candidates and owner contracts before building.") + + source = result.get("index_source", {}) + if source.get("kind") == "hosted": + lines.extend(["", f"Source: {source['url']} ({source['capability_count']} capabilities)", + f"Composed: {source['composed_at']}; fetched: {source['fetched_at']}", + f"Snapshot SHA-256: `{source['sha256']}`"]) warning = result.get("federated_index_stale_warning") if warning: diff --git a/reuse_surface/plan_snapshot.py b/reuse_surface/plan_snapshot.py new file mode 100644 index 0000000..b88ceec --- /dev/null +++ b/reuse_surface/plan_snapshot.py @@ -0,0 +1,109 @@ +"""Read-only hosted discovery with explicit, bounded source acceptance.""" +from __future__ import annotations + +import hashlib +import json +import math +import urllib.error +import urllib.parse +import urllib.request +from datetime import date, datetime, timedelta, timezone +from typing import Any + +MAX_SNAPSHOT_BYTES = 5 * 1024 * 1024 + + +class SnapshotUnavailable(ValueError): + """The selected source cannot support a planning recommendation.""" + + +def load_hosted_snapshot( + base_url: str, *, max_age_hours: float = 24 +) -> tuple[list[dict[str, Any]], str, dict[str, Any]]: + """Fetch the public index; never send intent, tokens or mutation requests. + + Acceptance concerns the hub's composition and warning signals. It does not + certify that every upstream repository has published its latest capability. + Failure must not become a local fallback or a 'new capability' verdict. + """ + url = base_url.rstrip("/") + "/v1/federated" + try: + parsed = urllib.parse.urlsplit(base_url) + valid_url = (parsed.scheme in {"http", "https"} and parsed.hostname + and not parsed.username and not parsed.password + and not parsed.query and not parsed.fragment) + except ValueError: + valid_url = False + if not valid_url: + raise SnapshotUnavailable("hub URL must be HTTP(S), without credentials, query or fragment") + if not math.isfinite(max_age_hours) or max_age_hours <= 0: + raise SnapshotUnavailable("maximum index age must be a positive finite number of hours") + + request = urllib.request.Request( + url, headers={"Accept": "application/json", "User-Agent": "reuse-surface/0.1"} + ) + try: + with urllib.request.urlopen(request, timeout=30) as response: + if response.status != 200: + raise SnapshotUnavailable(f"hosted index returned HTTP {response.status}") + if response.headers.get("X-Federation-Warnings"): + raise SnapshotUnavailable("hosted index reports federation warnings; inspect its sources") + raw = response.read(MAX_SNAPSHOT_BYTES + 1) + except urllib.error.HTTPError as exc: + raise SnapshotUnavailable(f"hosted index returned HTTP {exc.code}") from exc + except (urllib.error.URLError, OSError, ValueError) as exc: + if isinstance(exc, SnapshotUnavailable): + raise + raise SnapshotUnavailable("hosted index could not be fetched; no local fallback used") from exc + if len(raw) > MAX_SNAPSHOT_BYTES: + raise SnapshotUnavailable("hosted index exceeds the 5 MiB response limit") + try: + payload = json.loads(raw) + except (ValueError, UnicodeError) as exc: + raise SnapshotUnavailable("hosted index is not valid JSON") from exc + if not isinstance(payload, dict) or payload.get("stale") is not False: + raise SnapshotUnavailable("hosted index is stale or its freshness signal is missing") + + now = datetime.now(timezone.utc) + try: + composed = datetime.fromisoformat(payload["composed_at"]) + updated = payload["updated"] + date.fromisoformat(updated) + if composed.tzinfo is None: + raise ValueError("timezone missing") + except (KeyError, TypeError, ValueError) as exc: + raise SnapshotUnavailable("hosted index has invalid composition metadata") from exc + age = (now - composed).total_seconds() + if age < -timedelta(minutes=5).total_seconds() or age > max_age_hours * 3600: + raise SnapshotUnavailable("hosted index composition is outside the accepted age window") + + capabilities = payload.get("capabilities") + if not isinstance(capabilities, list) or not capabilities: + raise SnapshotUnavailable("hosted index has no usable capability collection") + ids: set[str] = set() + for item in capabilities: + if (not isinstance(item, dict) + or not isinstance(item.get("id"), str) or not item["id"].strip() + or item["id"] in ids + or not isinstance(item.get("name"), str) + or not isinstance(item.get("summary"), str) + or not isinstance(item.get("tags", []), list) + or any(not isinstance(tag, str) for tag in item.get("tags", [])) + or any(item.get(key) is not None and not isinstance(item[key], str) + for key in ("owner", "vector"))): + raise SnapshotUnavailable("hosted index contains malformed or duplicate capabilities") + ids.add(item["id"]) + + provenance = { + "kind": "hosted", + "url": url, + "fetched_at": now.isoformat(), + "composed_at": payload["composed_at"], + "capability_count": len(capabilities), + "sha256": hashlib.sha256(raw).hexdigest(), + "digest_format": "response-bytes", + "max_age_hours": max_age_hours, + "stale": False, + "federation_warnings": False, + } + return capabilities, updated, provenance diff --git a/tests/test_hosted_plan_check.py b/tests/test_hosted_plan_check.py new file mode 100644 index 0000000..df687a6 --- /dev/null +++ b/tests/test_hosted_plan_check.py @@ -0,0 +1,126 @@ +from __future__ import annotations + +import hashlib +import io +import json +import urllib.error +from datetime import datetime, timedelta, timezone + +import pytest + +from reuse_surface.cli import main +from reuse_surface.plan_check import load_query_from_intent, run_plan_check +from reuse_surface.plan_snapshot import MAX_SNAPSHOT_BYTES, SnapshotUnavailable + + +def snapshot(): + return { + "updated": datetime.now(timezone.utc).date().isoformat(), + "composed_at": datetime.now(timezone.utc).isoformat(), + "stale": False, + "capabilities": [{"id": "capability.hosted.only", "name": "Tender evidence", + "summary": "Tender evidence", "tags": ["tender"]}], + } + + +def serve(monkeypatch, payload, *, warnings=None, raw=None): + body = raw if raw is not None else json.dumps(payload).encode() + + def urlopen(request, *, timeout): + assert request.full_url == "https://registry.example/v1/federated" + assert request.get_method() == "GET" + assert request.data is None + assert not request.has_header("Authorization") + assert timeout == 30 + response = io.BytesIO(body) + response.status = 200 + response.headers = {"X-Federation-Warnings": warnings} if warnings else {} + return response + + monkeypatch.setattr("urllib.request.urlopen", urlopen) + return body + + +def check(**kwargs): + return run_plan_check(load_query_from_intent("Tender evidence"), + hub_url="https://registry.example", use_llm=False, **kwargs) + + +def test_hosted_only_capability_and_provenance_without_local_fallback(monkeypatch): + raw = serve(monkeypatch, snapshot()) + monkeypatch.setenv("REUSE_SURFACE_TOKEN", "must-never-be-sent") + monkeypatch.setattr("reuse_surface.plan_check.load_federated_capabilities", + lambda: pytest.fail("local source must not be consulted")) + result = check() + assert result["verdict"] == "reuse" + assert result["matches"][0]["id"] == "capability.hosted.only" + assert result["index_source"]["sha256"] == hashlib.sha256(raw).hexdigest() + assert result["index_source"]["capability_count"] == 1 + + +@pytest.mark.parametrize("change", [ + {"stale": True}, {"stale": None}, {"composed_at": None}, + {"composed_at": "2020-01-01T00:00:00Z"}, + {"composed_at": "2026-01-01T00:00:00"}, {"updated": "bad-date"}, + {"capabilities": []}, {"capabilities": [None]}, {"capabilities": {}}, + {"capabilities": [{"id": "bad", "name": "bad", "summary": "bad", "tags": None}]}, +]) +def test_invalid_source_never_produces_a_verdict(monkeypatch, change): + serve(monkeypatch, snapshot() | change) + with pytest.raises(SnapshotUnavailable): + check() + + +def test_partial_federation_refuses_before_verdict_and_side_effects(monkeypatch, capsys): + serve(monkeypatch, snapshot(), warnings="one source unavailable") + monkeypatch.setattr("reuse_surface.cli.record_outcome", + lambda *a, **k: pytest.fail("must not record a failed check")) + monkeypatch.setattr("reuse_surface.cli.maybe_file_capability_request", + lambda *a, **k: pytest.fail("must not create demand on source failure")) + assert main(["plan-check", "--intent", "unrelated", "--hub-url", + "https://registry.example", "--no-llm", "--format", "json", + "--record-outcome", "new", "--file-request"]) == 2 + output = capsys.readouterr() + assert output.out == "" + assert "federation warnings" in output.err + + +@pytest.mark.parametrize("error", [ + urllib.error.URLError("unreachable"), TimeoutError(), + urllib.error.HTTPError("https://registry.example", 503, "down", {}, None), +]) +def test_transport_failure_is_not_an_empty_index(monkeypatch, error): + def fail(*args, **kwargs): + raise error + monkeypatch.setattr("urllib.request.urlopen", fail) + with pytest.raises(SnapshotUnavailable): + check() + + +@pytest.mark.parametrize("raw", [b"login", b"[]", b"x" * (MAX_SNAPSHOT_BYTES + 1)]) +def test_invalid_or_oversized_response(monkeypatch, raw): + serve(monkeypatch, None, raw=raw) + with pytest.raises(SnapshotUnavailable): + check() + + +@pytest.mark.parametrize("age", [-2, 0, float("nan"), float("inf")]) +def test_invalid_age_limit(monkeypatch, age): + monkeypatch.setattr("urllib.request.urlopen", lambda *a, **k: pytest.fail("invalid input")) + with pytest.raises(SnapshotUnavailable): + check(max_index_age_hours=age) + + +def test_future_and_over_age_composition(monkeypatch): + for delta in (timedelta(hours=1), timedelta(hours=-25)): + serve(monkeypatch, snapshot() | {"composed_at": (datetime.now(timezone.utc) + delta).isoformat()}) + with pytest.raises(SnapshotUnavailable): + check() + + +def test_duplicate_ids_refused(monkeypatch): + payload = snapshot() + payload["capabilities"] *= 2 + serve(monkeypatch, payload) + with pytest.raises(SnapshotUnavailable): + check() diff --git a/tools/README.md b/tools/README.md index c69629b..9ab9914 100644 --- a/tools/README.md +++ b/tools/README.md @@ -318,4 +318,27 @@ Stable IDs and maturity fields are preserved for agent consumption (UC-RS-019). - UC-RS-013 — Use registry metadata in agentic coding - UC-RS-019 — Publish a machine-readable registry export -- UC-RS-023 — Validate registry entries against schema \ No newline at end of file +- UC-RS-023 — Validate registry entries against schema +### Hosted query-before-build + +The default `plan-check` still uses the local composed index. To consult the +hosted registry explicitly: + +```bash +reuse-surface plan-check --intent "tender document evidence" \ + --hub-url https://reuse.coulomb.social --no-llm --format json +``` + +Hosted mode fetches the public `GET /v1/federated`; it does not send the query or +a mutation token. It requires a nonempty usable collection, `stale: false`, no +`X-Federation-Warnings`, and a timezone-aware composition no older than 24 hours +(overridable with positive finite `--max-index-age-hours`). Malformed, oversized, +unavailable, stale and partial responses exit **2** with no verdict or opt-in +outcome/request side effects. There is no automatic local fallback. + +`index_source` records URL, capability count, composition/retrieval times and +SHA-256 of the response bytes. This is evidence about the accepted hub snapshot; +it does not certify every upstream repository's publication freshness. The +matching verdict is lexical advice; review candidates and owner contracts before +building, including when the verdict is `new`. `--no-llm` makes the check free of +additional model inference. Vergabe-teilnahme consumes it through `make plan-check`. diff --git a/workplans/REUSE-WP-0022-hosted-plan-check.md b/workplans/REUSE-WP-0022-hosted-plan-check.md new file mode 100644 index 0000000..a8284d2 --- /dev/null +++ b/workplans/REUSE-WP-0022-hosted-plan-check.md @@ -0,0 +1,54 @@ +--- +id: REUSE-WP-0022 +type: workplan +title: "Use current hosted capability discovery in factory planning" +domain: infotech +repo: reuse-surface +status: active +owner: the-custodian +topic_slug: helix-forge +created: "2026-09-08" +updated: "2026-09-08" +related: [HFACT-WP-0001, VERGABE-WP-0018] +--- + +# Hosted query-before-build + +## Core Idea + +The hosted index contains capabilities absent from the checked-in federation. +Provide an explicit hosted plan-check that fetches current discovery, preserves +its provenance and refuses incomplete or stale source evidence before producing +a recommendation. Vergabe-teilnahme is the first delivery-workflow consumer. + +## Implement and verify hosted planning + +```task +id: REUSE-WP-0022-T01 +status: progress +priority: high +assignee: the-custodian +``` + +Use the existing public GET /v1/federated. Preserve local mode compatibility. +Accept only bounded, structurally usable, recently composed, unwarned responses; +report endpoint, counts, composition/retrieval times and response digest. Never +convert source failure into a NEW verdict, gap request or local fallback. Test +hosted-only matches, malformed/stale/partial sources and transport failures. +No additional service, model call or mutation credential is necessary. + +## Publish and return the consumer receipt + +```task +id: REUSE-WP-0022-T02 +status: wait +priority: high +assignee: the-custodian +depends_on: [REUSE-WP-0022-T01] +blocking_reason: "Await tested source and reviewable PR, then VERGABE-WP-0018 hosted discovery receipt." +``` + +Publish the tested implementation through the existing Forgejo review path. +Capture a real hosted check invoked from vergabe-teilnahme with pinned source +revision. Return source and consumer evidence to HFACT-WP-0001-T06; it is source +preparation, not proof that the governed Railiance worker performed the change.