Use explicit fresh hosted capability snapshots for plan checks
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a07ff8-19d0-7820-b4d0-1353833cb7fc
This commit is contained in:
parent
5501b8bb56
commit
2621cf2752
6 changed files with 353 additions and 11 deletions
|
|
@ -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())
|
||||
raise SystemExit(main())
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
109
reuse_surface/plan_snapshot.py
Normal file
109
reuse_surface/plan_snapshot.py
Normal file
|
|
@ -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
|
||||
126
tests/test_hosted_plan_check.py
Normal file
126
tests/test_hosted_plan_check.py
Normal file
|
|
@ -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"<html>login</html>", 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()
|
||||
|
|
@ -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
|
||||
- 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`.
|
||||
|
|
|
|||
54
workplans/REUSE-WP-0022-hosted-plan-check.md
Normal file
54
workplans/REUSE-WP-0022-hosted-plan-check.md
Normal file
|
|
@ -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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue