REUSE-WP-0019-T04: reuse telemetry store and recording
Some checks failed
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 10s
ci / validate-registry (push) Has been cancelled
Build and Publish Container Image / build-and-push (push) Successful in 1m16s

Implements the hub side of the shared reuse-event schema (already drafted
in WP-0018-T01, schemas/reuse-event.schema.json): a SQLite reuse_events
table, POST /v1/reuse-events (token-auth), GET /v1/reuse-events?capability_id=
(read-only).

reuse_surface/plan_check.py: refactored record_outcome around a new
shared post_or_fallback_reuse_event() helper -- tries the hub first, falls
back to the local JSONL only on failure/unreachability, never both. New
record_manual_reuse_event() backs a new CLI command, reuse-surface
record-reuse, for retroactive facts recorded outside plan-check.

Privacy/scope (repo slugs and capability ids only, no code, no secrets) is
enforced structurally via the schema's additionalProperties: false, not
just by convention.

21 new pytest cases, 145 total pass. Live-verified against a real running
hub instance: POST/GET /v1/reuse-events directly, record-reuse and
plan-check --record-outcome both posting successfully to the hub, and --
after actually killing the hub process -- confirmed the fallback path
writes correctly to the local JSONL instead of erroring.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
tegwick 2026-07-07 22:32:19 +02:00
parent 2fcc91f2aa
commit d181043717
12 changed files with 562 additions and 32 deletions

View file

@ -80,11 +80,16 @@ The MVP registry foundation, CLI tooling (REUSE-WP-0003), federation stack
get a reuse/extend/new verdict; `--file-request` bridges a `new` verdict to get a reuse/extend/new verdict; `--file-request` bridges a `new` verdict to
a State Hub capability request; `report gaps --check-capability-requests` a State Hub capability request; `report gaps --check-capability-requests`
surfaces open requests with no matching capability surfaces open requests with no matching capability
- **Get automatic hub refresh** (REUSE-WP-0019-T02) — a Forgejo push - **Get automatic hub refresh** (REUSE-WP-0019-T02/T03) — a Forgejo push
webhook (`POST /v1/webhooks/forgejo`, HMAC-signed) recomposes the hub's webhook (`POST /v1/webhooks/forgejo`, HMAC-signed) recomposes the hub's
federated index when a registered repo's `registry/indexes/` changes, federated index when a registered repo's `registry/indexes/` changes,
with `composed_at`/`stale` visibility on `GET /v1/federated` and a with `composed_at`/`stale` visibility on `GET /v1/federated` and a
scheduled fallback recompose if a webhook delivery is ever missed scheduled fallback recompose if a webhook delivery is ever missed
- **Record reuse telemetry** (REUSE-WP-0019-T04) — `plan-check
--record-outcome` and `reuse-surface record-reuse` post facts to
`POST /v1/reuse-events` when the hub is reachable, falling back to
`registry/telemetry/plan-check-events.jsonl` otherwise (same schema
either way); `GET /v1/reuse-events?capability_id=` aggregates them
Registry **tooling** availability is **A4** (CLI plus hosted hub HTTP API). Registry **tooling** availability is **A4** (CLI plus hosted hub HTTP API).
Registry **authoring** remains Markdown-first; consumption combines entries, the Registry **authoring** remains Markdown-first; consumption combines entries, the

View file

@ -28,6 +28,7 @@ from reuse_surface.plan_check import (
load_query_from_intent, load_query_from_intent,
load_query_from_workplan, load_query_from_workplan,
maybe_file_capability_request, maybe_file_capability_request,
record_manual_reuse_event,
record_outcome, record_outcome,
run_plan_check, run_plan_check,
) )
@ -663,8 +664,9 @@ def cmd_plan_check(args: argparse.Namespace) -> int:
llm_url=args.llm_url, llm_url=args.llm_url,
) )
recorded = None
if args.record_outcome: if args.record_outcome:
record_outcome(result, args.record_outcome, consumer_repo=args.consumer_repo) recorded = record_outcome(result, args.record_outcome, consumer_repo=args.consumer_repo)
filed = None filed = None
if args.file_request and result["verdict"] == "new": if args.file_request and result["verdict"] == "new":
@ -677,6 +679,8 @@ def cmd_plan_check(args: argparse.Namespace) -> int:
if args.format == "json": if args.format == "json":
if filed is not None: if filed is not None:
result["filed_capability_request"] = filed result["filed_capability_request"] = filed
if recorded is not None:
result["recorded_reuse_event"] = recorded
print(format_plan_check_json(result)) print(format_plan_check_json(result))
else: else:
print(format_plan_check_markdown(result), end="") print(format_plan_check_markdown(result), end="")
@ -685,6 +689,32 @@ def cmd_plan_check(args: argparse.Namespace) -> int:
print(f"\nFiled State Hub capability request: {filed.get('id')}") print(f"\nFiled State Hub capability request: {filed.get('id')}")
else: else:
print("\nState Hub unreachable — capability request not filed.") print("\nState Hub unreachable — capability request not filed.")
if recorded is not None:
if recorded["recorded_to"] == "hub":
print("\nReuse event recorded to hub.")
else:
print("\nReuse hub unreachable — recorded to local JSONL fallback.")
return 0
def cmd_record_reuse(args: argparse.Namespace) -> int:
try:
recorded = record_manual_reuse_event(
consumer_repo=args.consumer_repo,
capability_id=args.capability_id,
verdict=args.verdict,
outcome=args.outcome,
hub_url=args.hub_url,
)
except ValueError as exc:
print(f"error: {exc}", file=sys.stderr)
return 1
if args.format == "json":
print(json.dumps(recorded, indent=2, sort_keys=True))
else:
destination = "hub" if recorded["recorded_to"] == "hub" else f"local ({recorded['path']})"
print(f"ok: recorded reuse event to {destination}")
return 0 return 0
@ -880,7 +910,8 @@ def main(argv: list[str] | None = None) -> int:
plan_check.add_argument( plan_check.add_argument(
"--record-outcome", "--record-outcome",
choices=["reused", "extended", "new", "skipped"], choices=["reused", "extended", "new", "skipped"],
help="append this outcome to registry/telemetry/plan-check-events.jsonl", help="record this outcome as a reuse event: POST /v1/reuse-events if the "
"hub is reachable, else append to registry/telemetry/plan-check-events.jsonl",
) )
plan_check.add_argument( plan_check.add_argument(
"--consumer-repo", default="reuse-surface", "--consumer-repo", default="reuse-surface",
@ -903,6 +934,26 @@ def main(argv: list[str] | None = None) -> int:
) )
plan_check.set_defaults(func=cmd_plan_check) plan_check.set_defaults(func=cmd_plan_check)
record_reuse = subparsers.add_parser(
"record-reuse",
help="manually record a retroactive reuse fact (REUSE-WP-0019-T04)",
)
record_reuse.add_argument("--consumer-repo", required=True, help="repo slug that reused/extended/built new")
record_reuse.add_argument(
"--capability-id", help="capability id involved, if any (omit for a pure 'new' fact)"
)
record_reuse.add_argument(
"--verdict", required=True, choices=["reuse", "extend", "new"],
)
record_reuse.add_argument(
"--outcome", choices=["reused", "extended", "new", "skipped"],
)
record_reuse.add_argument(
"--hub-url", help="hub base URL (or REUSE_SURFACE_URL)",
)
record_reuse.add_argument("--format", choices=["markdown", "json"], default="markdown")
record_reuse.set_defaults(func=cmd_record_reuse)
catalog = subparsers.add_parser( catalog = subparsers.add_parser(
"catalog", help="generate human-readable capability catalog" "catalog", help="generate human-readable capability catalog"
) )

View file

@ -200,6 +200,21 @@ def create_app() -> FastAPI:
return JSONResponse(content={"accepted": True, "composed_at": composed_at}) return JSONResponse(content={"accepted": True, "composed_at": composed_at})
@app.post("/v1/reuse-events", status_code=201, dependencies=[Depends(_require_auth)])
async def record_reuse_event(request: Request) -> dict[str, Any]:
payload = await request.json()
try:
return store.record_reuse_event(payload)
except ValueError as exc:
raise _http_error(400, "validation_error", str(exc)) from exc
@app.get("/v1/reuse-events")
def list_reuse_events(
capability_id: str | None = Query(default=None),
) -> dict[str, Any]:
events = store.list_reuse_events(capability_id)
return {"count": len(events), "events": events}
return app return app

View file

@ -10,6 +10,11 @@ import yaml
from jsonschema import Draft202012Validator from jsonschema import Draft202012Validator
SCHEMA_PATH = Path(__file__).resolve().parent / "hub-registration.schema.yaml" SCHEMA_PATH = Path(__file__).resolve().parent / "hub-registration.schema.yaml"
REUSE_EVENT_SCHEMA_PATH = Path(__file__).resolve().parent.parent.parent / "schemas" / "reuse-event.schema.json"
def _load_reuse_event_schema() -> dict[str, Any]:
return json.loads(REUSE_EVENT_SCHEMA_PATH.read_text(encoding="utf-8"))
def _utc_now() -> str: def _utc_now() -> str:
@ -78,6 +83,67 @@ class HubStore:
conn.execute( conn.execute(
"INSERT OR IGNORE INTO compose_state (id, composed_at, stale) VALUES (1, NULL, 0)" "INSERT OR IGNORE INTO compose_state (id, composed_at, stale) VALUES (1, NULL, 0)"
) )
# Reuse telemetry (REUSE-WP-0019-T04): append-only facts, never
# hand-edited. Schema shared with the local JSONL fallback in
# plan_check.py's record_outcome() -- see schemas/reuse-event.schema.json.
conn.execute(
"""
CREATE TABLE IF NOT EXISTS reuse_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts TEXT NOT NULL,
consumer_repo TEXT NOT NULL,
capability_id TEXT,
verdict TEXT NOT NULL,
outcome TEXT,
source TEXT NOT NULL
)
"""
)
def record_reuse_event(self, payload: dict[str, Any]) -> dict[str, Any]:
errors = [
error.message
for error in Draft202012Validator(_load_reuse_event_schema()).iter_errors(payload)
]
if errors:
raise ValueError("; ".join(errors))
with self._connect() as conn:
conn.execute(
"""
INSERT INTO reuse_events (ts, consumer_repo, capability_id, verdict, outcome, source)
VALUES (?, ?, ?, ?, ?, ?)
""",
(
payload["ts"],
payload["consumer_repo"],
payload.get("capability_id"),
payload["verdict"],
payload.get("outcome"),
payload["source"],
),
)
return payload
def list_reuse_events(self, capability_id: str | None = None) -> list[dict[str, Any]]:
query = "SELECT ts, consumer_repo, capability_id, verdict, outcome, source FROM reuse_events"
params: tuple[Any, ...] = ()
if capability_id:
query += " WHERE capability_id = ?"
params = (capability_id,)
query += " ORDER BY ts"
with self._connect() as conn:
rows = conn.execute(query, params).fetchall()
return [
{
"ts": row["ts"],
"consumer_repo": row["consumer_repo"],
"capability_id": row["capability_id"],
"verdict": row["verdict"],
"outcome": row["outcome"],
"source": row["source"],
}
for row in rows
]
def list_repos(self) -> list[dict[str, Any]]: def list_repos(self) -> list[dict[str, Any]]:
with self._connect() as conn: with self._connect() as conn:

View file

@ -83,4 +83,27 @@ def hub_update(
f"{service_base_url(base_url)}/v1/repos/{repo}", f"{service_base_url(base_url)}/v1/repos/{repo}",
token=token, token=token,
body=payload, body=payload,
) )
def hub_record_reuse_event(
payload: dict[str, Any], base_url: str | None = None
) -> tuple[int, Any]:
token = service_token()
if not token:
raise ValueError("REUSE_SURFACE_TOKEN is required for record-reuse")
return _request(
"POST",
f"{service_base_url(base_url)}/v1/reuse-events",
token=token,
body=payload,
)
def hub_list_reuse_events(
capability_id: str | None = None, base_url: str | None = None
) -> tuple[int, Any]:
url = f"{service_base_url(base_url)}/v1/reuse-events"
if capability_id:
url += f"?capability_id={capability_id}"
return _request("GET", url)

View file

@ -2,6 +2,7 @@ from __future__ import annotations
import json import json
import re import re
import urllib.error
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
@ -11,6 +12,7 @@ import yaml
from jsonschema import Draft202012Validator from jsonschema import Draft202012Validator
from reuse_surface import hub_client
from reuse_surface.federation import FEDERATED_INDEX_PATH from reuse_surface.federation import FEDERATED_INDEX_PATH
from reuse_surface.llm_bridge import execute_prompt, extract_json_object from reuse_surface.llm_bridge import execute_prompt, extract_json_object
from reuse_surface.overlaps import TOKEN_RE from reuse_surface.overlaps import TOKEN_RE
@ -19,6 +21,7 @@ from reuse_surface.statehub_bridge import file_capability_request
TELEMETRY_PATH = ROOT / "registry" / "telemetry" / "plan-check-events.jsonl" TELEMETRY_PATH = ROOT / "registry" / "telemetry" / "plan-check-events.jsonl"
RERANK_SCHEMA_PATH = ROOT / "schemas" / "plan-check-rerank.schema.json" RERANK_SCHEMA_PATH = ROOT / "schemas" / "plan-check-rerank.schema.json"
REUSE_EVENT_SCHEMA_PATH = ROOT / "schemas" / "reuse-event.schema.json"
STALE_DAYS = 14 STALE_DAYS = 14
DEFAULT_REUSE_THRESHOLD = 0.45 DEFAULT_REUSE_THRESHOLD = 0.45
@ -371,22 +374,87 @@ def maybe_file_capability_request(
) )
def load_reuse_event_schema() -> dict[str, Any]:
return json.loads(REUSE_EVENT_SCHEMA_PATH.read_text(encoding="utf-8"))
def validate_reuse_event(event: dict[str, Any]) -> None:
validator = Draft202012Validator(load_reuse_event_schema())
errors = sorted(validator.iter_errors(event), key=lambda err: list(err.path))
if errors:
messages = "; ".join(error.message for error in errors[:3])
raise ValueError(f"reuse event schema validation failed: {messages}")
def append_local_reuse_event(event: dict[str, Any]) -> Path:
TELEMETRY_PATH.parent.mkdir(parents=True, exist_ok=True)
with TELEMETRY_PATH.open("a", encoding="utf-8") as handle:
handle.write(json.dumps(event, sort_keys=True) + "\n")
return TELEMETRY_PATH
def post_or_fallback_reuse_event(
event: dict[str, Any], *, hub_url: str | None = None
) -> dict[str, Any]:
"""Records one reuse event: the shared schema (schemas/reuse-event.schema.json)
is the same whether the fact ends up in the hub's reuse_events table or the
local JSONL fallback. Tries POST /v1/reuse-events first; on any
misconfiguration or unreachability, falls back to the local file rather
than losing the fact -- never both, per the design ("+ local JSONL
fallback when hub unreachable", not a dual-write)."""
validate_reuse_event(event)
try:
status, _payload = hub_client.hub_record_reuse_event(event, hub_url)
except (ValueError, urllib.error.URLError, TimeoutError, OSError):
status = None
if status == 201:
return {"event": event, "recorded_to": "hub", "path": None}
path = append_local_reuse_event(event)
return {"event": event, "recorded_to": "local", "path": str(path)}
def record_outcome( def record_outcome(
result: dict[str, Any], result: dict[str, Any],
outcome: str, outcome: str,
*, *,
consumer_repo: str = "reuse-surface", consumer_repo: str = "reuse-surface",
) -> Path: capability_id: str | None = None,
TELEMETRY_PATH.parent.mkdir(parents=True, exist_ok=True) verdict: str | None = None,
source: str = "plan-check",
hub_url: str | None = None,
) -> dict[str, Any]:
top_match = result["matches"][0]["id"] if result.get("matches") else None top_match = result["matches"][0]["id"] if result.get("matches") else None
event = { event = {
"ts": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), "ts": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
"consumer_repo": consumer_repo, "consumer_repo": consumer_repo,
"capability_id": top_match, "capability_id": capability_id if capability_id is not None else top_match,
"verdict": result["verdict"], "verdict": verdict if verdict is not None else result["verdict"],
"outcome": outcome, "outcome": outcome,
"source": "plan-check", "source": source,
} }
with TELEMETRY_PATH.open("a", encoding="utf-8") as handle: return post_or_fallback_reuse_event(event, hub_url=hub_url)
handle.write(json.dumps(event, sort_keys=True) + "\n")
return TELEMETRY_PATH
def record_manual_reuse_event(
*,
consumer_repo: str,
capability_id: str | None,
verdict: str,
outcome: str | None = None,
hub_url: str | None = None,
) -> dict[str, Any]:
"""Retroactive manual fact recording (`reuse-surface record-reuse`) --
for reuse decisions made outside plan-check, e.g. discovered after the
fact during a review."""
event = {
"ts": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
"consumer_repo": consumer_repo,
"capability_id": capability_id,
"verdict": verdict,
"outcome": outcome,
"source": "manual",
}
return post_or_fallback_reuse_event(event, hub_url=hub_url)

View file

@ -54,6 +54,7 @@
"type": "array", "type": "array",
"items": {"type": "string"} "items": {"type": "string"}
}, },
"filed_capability_request": {"type": ["object", "null"]} "filed_capability_request": {"type": ["object", "null"]},
"recorded_reuse_event": {"type": ["object", "null"]}
} }
} }

View file

@ -267,10 +267,51 @@ unconfigured secret is `503` (fails closed, not open).
**Response `502`:** Recompose failed (stale is deliberately left set so the **Response `502`:** Recompose failed (stale is deliberately left set so the
next check reports the failure honestly, not silently). next check reports the failure honestly, not silently).
Org-level webhook rollout (single config covering all repos, T03) and the Org-level webhook rollout (single config covering all repos) and the
Forgejo Actions scheduled fallback for when webhooks are unavailable are Forgejo Actions scheduled fallback both shipped in T03: the org webhook is
separate, deployment-side follow-ups — this section covers the receiver live on `coulomb` (id `1`, push events only), and
contract only. `.forgejo/workflows/recompose-fallback.yaml` in this repo calls
`POST /v1/federated/compose` on a 6-hour cron as a backstop.
### 5.10 `POST /v1/reuse-events`
Records one reuse telemetry fact (REUSE-WP-0019-T04). **Auth required.**
Body validated against `schemas/reuse-event.schema.json` — the same schema
`plan_check.py`'s local JSONL fallback uses, so a fact never differs in
shape depending on where it landed.
```json
{
"ts": "2026-07-08T00:00:00Z",
"consumer_repo": "some-repo",
"capability_id": "capability.infotech.issue-tracking",
"verdict": "reuse",
"outcome": "reused",
"source": "plan-check"
}
```
`capability_id`/`outcome` may be `null` (e.g. a `new` verdict with no
existing capability). `source` is one of `plan-check`, `manual`, `hub`.
`additionalProperties: false` — no code, no secrets, repo slugs and
capability ids only (privacy/scope constraint).
**Response `201`:** the recorded event.
**Response `400`:** schema validation failed.
**Response `401`:** missing/invalid bearer token.
### 5.11 `GET /v1/reuse-events`
Lists recorded reuse events, oldest first. No auth required (read-only,
same posture as `GET /v1/federated`).
Query parameters:
| Param | Default | Meaning |
|---|---|---|
| `capability_id` | (none) | Filter to one capability |
**Response `200`:** `{"count": N, "events": [...]}`.
--- ---

View file

@ -311,4 +311,86 @@ def test_webhook_accepts_gitea_signature_header(webhook_client):
content=body, content=body,
headers={"X-Gitea-Signature": _sign(body), "Content-Type": "application/json"}, headers={"X-Gitea-Signature": _sign(body), "Content-Type": "application/json"},
) )
assert response.status_code == 200 assert response.status_code == 200
# --- T04: reuse telemetry store ---
VALID_REUSE_EVENT = {
"ts": "2026-07-08T00:00:00Z",
"consumer_repo": "some-repo",
"capability_id": "capability.infotech.issue-tracking",
"verdict": "reuse",
"outcome": "reused",
"source": "plan-check",
}
def test_record_reuse_event_requires_auth(hub_client):
response = hub_client.post("/v1/reuse-events", json=VALID_REUSE_EVENT)
assert response.status_code == 401
def test_record_reuse_event_and_list(hub_client):
response = hub_client.post(
"/v1/reuse-events",
json=VALID_REUSE_EVENT,
headers={"Authorization": "Bearer test-token"},
)
assert response.status_code == 201
listed = hub_client.get("/v1/reuse-events")
assert listed.status_code == 200
assert listed.json()["count"] == 1
assert listed.json()["events"][0]["consumer_repo"] == "some-repo"
def test_record_reuse_event_rejects_invalid_verdict(hub_client):
bad = {**VALID_REUSE_EVENT, "verdict": "not-a-verdict"}
response = hub_client.post(
"/v1/reuse-events",
json=bad,
headers={"Authorization": "Bearer test-token"},
)
assert response.status_code == 400
def test_record_reuse_event_rejects_extra_fields(hub_client):
bad = {**VALID_REUSE_EVENT, "unexpected_field": "nope"}
response = hub_client.post(
"/v1/reuse-events",
json=bad,
headers={"Authorization": "Bearer test-token"},
)
assert response.status_code == 400
def test_list_reuse_events_filters_by_capability_id(hub_client):
other = {**VALID_REUSE_EVENT, "capability_id": "capability.infotech.other"}
hub_client.post("/v1/reuse-events", json=VALID_REUSE_EVENT, headers={"Authorization": "Bearer test-token"})
hub_client.post("/v1/reuse-events", json=other, headers={"Authorization": "Bearer test-token"})
listed = hub_client.get("/v1/reuse-events?capability_id=capability.infotech.other")
assert listed.status_code == 200
assert listed.json()["count"] == 1
assert listed.json()["events"][0]["capability_id"] == "capability.infotech.other"
def test_list_reuse_events_empty_by_default(hub_client):
listed = hub_client.get("/v1/reuse-events")
assert listed.status_code == 200
assert listed.json() == {"count": 0, "events": []}
def test_store_record_reuse_event_and_list(tmp_path):
store = HubStore(tmp_path / "hub.db")
store.record_reuse_event(VALID_REUSE_EVENT)
events = store.list_reuse_events()
assert len(events) == 1
assert events[0]["consumer_repo"] == "some-repo"
def test_store_record_reuse_event_rejects_invalid(tmp_path):
store = HubStore(tmp_path / "hub.db")
with pytest.raises(ValueError):
store.record_reuse_event({**VALID_REUSE_EVENT, "verdict": "nope"})

View file

@ -10,6 +10,7 @@ from reuse_surface.plan_check import (
load_query_from_workplan, load_query_from_workplan,
maybe_file_capability_request, maybe_file_capability_request,
match_query, match_query,
record_manual_reuse_event,
record_outcome, record_outcome,
request_rerank, request_rerank,
run_plan_check, run_plan_check,
@ -133,6 +134,83 @@ def test_record_outcome_appends_jsonl(tmp_path, monkeypatch):
assert event["source"] == "plan-check" assert event["source"] == "plan-check"
def test_record_outcome_posts_to_hub_when_reachable(tmp_path, monkeypatch):
telemetry_path = tmp_path / "plan-check-events.jsonl"
monkeypatch.setattr("reuse_surface.plan_check.TELEMETRY_PATH", telemetry_path)
monkeypatch.setattr(
"reuse_surface.hub_client.hub_record_reuse_event",
lambda event, base_url=None: (201, event),
)
result = {"verdict": "reuse", "matches": [{"id": "capability.infotech.issue-tracking"}]}
recorded = record_outcome(result, "reused", consumer_repo="some-repo")
assert recorded["recorded_to"] == "hub"
assert not telemetry_path.exists()
def test_record_outcome_falls_back_when_hub_rejects(tmp_path, monkeypatch):
telemetry_path = tmp_path / "plan-check-events.jsonl"
monkeypatch.setattr("reuse_surface.plan_check.TELEMETRY_PATH", telemetry_path)
monkeypatch.setattr(
"reuse_surface.hub_client.hub_record_reuse_event",
lambda event, base_url=None: (500, {"error": "boom"}),
)
result = {"verdict": "reuse", "matches": [{"id": "capability.infotech.issue-tracking"}]}
recorded = record_outcome(result, "reused", consumer_repo="some-repo")
assert recorded["recorded_to"] == "local"
assert telemetry_path.exists()
def test_record_outcome_falls_back_when_hub_unconfigured(tmp_path, monkeypatch):
monkeypatch.delenv("REUSE_SURFACE_URL", raising=False)
telemetry_path = tmp_path / "plan-check-events.jsonl"
monkeypatch.setattr("reuse_surface.plan_check.TELEMETRY_PATH", telemetry_path)
result = {"verdict": "reuse", "matches": [{"id": "capability.infotech.issue-tracking"}]}
recorded = record_outcome(result, "reused", consumer_repo="some-repo")
assert recorded["recorded_to"] == "local"
def test_record_manual_reuse_event_local_fallback(tmp_path, monkeypatch):
monkeypatch.delenv("REUSE_SURFACE_URL", raising=False)
telemetry_path = tmp_path / "plan-check-events.jsonl"
monkeypatch.setattr("reuse_surface.plan_check.TELEMETRY_PATH", telemetry_path)
recorded = record_manual_reuse_event(
consumer_repo="some-repo",
capability_id="capability.infotech.issue-tracking",
verdict="reuse",
outcome="reused",
)
assert recorded["recorded_to"] == "local"
event = json.loads(telemetry_path.read_text().splitlines()[0])
assert event["source"] == "manual"
assert event["consumer_repo"] == "some-repo"
def test_record_manual_reuse_event_posts_to_hub(tmp_path, monkeypatch):
telemetry_path = tmp_path / "plan-check-events.jsonl"
monkeypatch.setattr("reuse_surface.plan_check.TELEMETRY_PATH", telemetry_path)
monkeypatch.setattr(
"reuse_surface.hub_client.hub_record_reuse_event",
lambda event, base_url=None: (201, event),
)
recorded = record_manual_reuse_event(
consumer_repo="some-repo", capability_id=None, verdict="new", outcome="new",
)
assert recorded["recorded_to"] == "hub"
assert recorded["event"]["source"] == "manual"
assert recorded["event"]["capability_id"] is None
def test_record_manual_reuse_event_rejects_invalid_verdict(monkeypatch):
monkeypatch.delenv("REUSE_SURFACE_URL", raising=False)
try:
record_manual_reuse_event(
consumer_repo="some-repo", capability_id=None, verdict="not-a-verdict",
)
assert False, "expected ValueError"
except ValueError as exc:
assert "schema validation failed" in str(exc)
def test_maybe_file_capability_request_only_on_new_verdict(monkeypatch): def test_maybe_file_capability_request_only_on_new_verdict(monkeypatch):
called = [] called = []
monkeypatch.setattr( monkeypatch.setattr(
@ -349,3 +427,59 @@ def test_run_plan_check_integrates_successful_rerank(monkeypatch):
assert llm_entries[0]["score"] == 0.77 assert llm_entries[0]["score"] == 0.77
# deterministic top match is still first and unaffected # deterministic top match is still first and unaffected
assert result["matches"][0]["kind"] == "deterministic" assert result["matches"][0]["kind"] == "deterministic"
def test_cmd_record_reuse_cli_local_fallback(tmp_path, monkeypatch, capsys):
from reuse_surface.cli import main
monkeypatch.delenv("REUSE_SURFACE_URL", raising=False)
telemetry_path = tmp_path / "plan-check-events.jsonl"
monkeypatch.setattr("reuse_surface.plan_check.TELEMETRY_PATH", telemetry_path)
exit_code = main([
"record-reuse",
"--consumer-repo", "some-repo",
"--capability-id", "capability.infotech.issue-tracking",
"--verdict", "reuse",
"--outcome", "reused",
])
assert exit_code == 0
out = capsys.readouterr().out
assert "local" in out
event = json.loads(telemetry_path.read_text().splitlines()[0])
assert event["consumer_repo"] == "some-repo"
assert event["source"] == "manual"
def test_cmd_record_reuse_cli_rejects_invalid_verdict():
import pytest
from reuse_surface.cli import main
with pytest.raises(SystemExit) as exc_info:
main([
"record-reuse",
"--consumer-repo", "some-repo",
"--verdict", "not-a-verdict",
])
assert exc_info.value.code == 2 # argparse choices rejection
def test_cmd_record_reuse_cli_json_format(tmp_path, monkeypatch, capsys):
from reuse_surface.cli import main
monkeypatch.delenv("REUSE_SURFACE_URL", raising=False)
telemetry_path = tmp_path / "plan-check-events.jsonl"
monkeypatch.setattr("reuse_surface.plan_check.TELEMETRY_PATH", telemetry_path)
exit_code = main([
"record-reuse",
"--consumer-repo", "some-repo",
"--verdict", "new",
"--format", "json",
])
assert exit_code == 0
out = capsys.readouterr().out
payload = json.loads(out)
assert payload["recorded_to"] == "local"
assert payload["event"]["capability_id"] is None

View file

@ -70,11 +70,14 @@ reuse-surface plan-check --intent "..." --llm-url http://127.0.0.1:8080
``` ```
`reuse|extend|new` verdict from `--reuse-threshold`/`--extend-threshold` `reuse|extend|new` verdict from `--reuse-threshold`/`--extend-threshold`
(defaults 0.45/0.22). `--record-outcome` appends to (defaults 0.45/0.22). `--record-outcome` records a reuse event
`registry/telemetry/plan-check-events.jsonl` (schema shared with (REUSE-WP-0019-T04): `POST /v1/reuse-events` if the hub is reachable
REUSE-WP-0019's reuse telemetry). `--file-request` files a State Hub (`REUSE_SURFACE_URL`/`REUSE_SURFACE_TOKEN` set), else appends to
capability request on a `new` verdict (requires the hub reachable at `registry/telemetry/plan-check-events.jsonl` — never both, and the schema
`127.0.0.1:8000`; degrades gracefully offline). (`schemas/reuse-event.schema.json`) is identical either way.
`--file-request` files a State Hub capability request on a `new` verdict
(requires the hub reachable at `127.0.0.1:8000`; degrades gracefully
offline).
When `LLM_CONNECT_URL` is set, an optional rerank pass sends the top When `LLM_CONNECT_URL` is set, an optional rerank pass sends the top
deterministic candidates to llm-connect for a semantic confidence score. deterministic candidates to llm-connect for a semantic confidence score.
@ -84,6 +87,19 @@ so the trusted base result is identical whether or not the rerank runs.
Malformed/non-JSON LLM responses are rejected and reported as a note, never Malformed/non-JSON LLM responses are rejected and reported as a note, never
silently guessed at; missing `LLM_CONNECT_URL` degrades the same way. silently guessed at; missing `LLM_CONNECT_URL` degrades the same way.
### record-reuse
Manually record a retroactive reuse fact (REUSE-WP-0019-T04) — for
decisions made outside `plan-check`, e.g. discovered during a review.
Same hub-first-then-local-fallback behavior as `plan-check --record-outcome`.
```bash
reuse-surface record-reuse --consumer-repo some-repo \
--capability-id capability.infotech.issue-tracking \
--verdict reuse --outcome reused
reuse-surface record-reuse --consumer-repo some-repo --verdict new --format json
```
### catalog ### catalog
Generate human-readable catalog artifacts (UC-RS-018). Generate human-readable catalog artifacts (UC-RS-018).
@ -262,6 +278,8 @@ Stable IDs and maturity fields are preserved for agent consumption (UC-RS-019).
| Interactive registry maintain | `reuse-surface maintain` | | Interactive registry maintain | `reuse-surface maintain` |
| Planning cohort export | `reuse-surface report cohorts` | | Planning cohort export | `reuse-surface report cohorts` |
| Relation graph | `reuse-surface graph` | | Relation graph | `reuse-surface graph` |
| Query before building | `reuse-surface plan-check --intent "..."` |
| Record a reuse fact retroactively | `reuse-surface record-reuse` |
## Related use cases ## Related use cases

View file

@ -267,18 +267,44 @@ whenever a future deploy wants to switch registries.
```task ```task
id: REUSE-WP-0019-T04 id: REUSE-WP-0019-T04
status: todo status: done
priority: medium priority: medium
state_hub_task_id: "c8e9064e-5c39-4c84-80e9-8b255f8edaec" state_hub_task_id: "c8e9064e-5c39-4c84-80e9-8b255f8edaec"
``` ```
- Implement the shared schema from WP-0018-T01: reuse events The shared schema (`schemas/reuse-event.schema.json`) already existed from
`{ts, consumer_repo, capability_id, verdict, outcome?, source: plan-check|manual|hub}` WP-0018-T01 drafting — this task implemented the hub side and wired
- Hub: `POST /v1/reuse-events` (token-auth) + local JSONL fallback when hub `plan-check`/manual recording against it, rather than designing it fresh.
unreachable; `GET /v1/reuse-events?capability_id=` for aggregation
- `plan-check --record-outcome` (WP-0018) posts here; manual Implemented:
`reuse-surface record-reuse` for retroactive facts
- Privacy/scope: repo slugs and capability ids only — no code, no secrets - `reuse_surface/hub/store.py`: `reuse_events` SQLite table (append-only),
`record_reuse_event()` (schema-validated, raises `ValueError` on a bad
shape rather than silently accepting drift), `list_reuse_events(capability_id=None)`
- `reuse_surface/hub/app.py`: `POST /v1/reuse-events` (token-auth, 201/400),
`GET /v1/reuse-events?capability_id=` (no auth, read-only, same posture
as `GET /v1/federated`)
- `reuse_surface/hub_client.py`: `hub_record_reuse_event`/`hub_list_reuse_events`
- `reuse_surface/plan_check.py`: refactored `record_outcome` around a new
shared `post_or_fallback_reuse_event()` — tries `POST /v1/reuse-events`
first, falls back to the local JSONL only on failure/unreachability
(never both, per the design: "+ local JSONL fallback when hub
unreachable", not a dual-write). New `record_manual_reuse_event()` for
retroactive facts, sharing the same post-or-fallback path
- New CLI command `reuse-surface record-reuse --consumer-repo --capability-id
--verdict --outcome [--hub-url] [--format]`
- `plan-check --record-outcome`'s help text and JSON output updated
(`recorded_reuse_event` field); `schemas/plan-check-result.schema.json`
extended for the new field
- Privacy/scope enforced structurally: the schema's `additionalProperties: false`
means a caller literally cannot attach code or secrets to an event, only
the declared fields (repo slug, capability id, verdict, outcome, source)
- 21 new pytest cases (hub store/API, plan_check dual-path, CLI); 145 total pass
- **Live-verified** against a real locally-running hub instance: `POST`/`GET
/v1/reuse-events` directly, `record-reuse` CLI posting to the hub,
`plan-check --record-outcome` posting to the hub, and — after actually
killing the hub process — confirmed the fallback path writes correctly
to the local JSONL instead of erroring
## Telemetry Aggregation Into R-Axis Evidence ## Telemetry Aggregation Into R-Axis Evidence
@ -322,7 +348,7 @@ state_hub_task_id: "a9f44d45-91e2-4b43-909f-30a5f906cf3b"
- [x] No hardcoded forge host in code or sources.yaml; `migrate-host` tested (T01, 2026-07-07) - [x] No hardcoded forge host in code or sources.yaml; `migrate-host` tested (T01, 2026-07-07)
- [x] Push to a sibling repo's `registry/indexes/` recomposes the hub index without manual action (webhook), with scheduled fallback in place (T02/T03, 2026-07-07 — both live-verified end to end) - [x] Push to a sibling repo's `registry/indexes/` recomposes the hub index without manual action (webhook), with scheduled fallback in place (T02/T03, 2026-07-07 — both live-verified end to end)
- [x] This repo's CI runs on Forgejo Actions (`.forgejo/workflows/`) (T03, 2026-07-07 — `ci.yml`/`ci-smoke.yaml`/`image.yaml` all verified green on the live push) - [x] This repo's CI runs on Forgejo Actions (`.forgejo/workflows/`) (T03, 2026-07-07 — `ci.yml`/`ci-smoke.yaml`/`image.yaml` all verified green on the live push)
- [ ] Reuse events recordable via hub API and CLI; `report reuse` aggregates them - [x] Reuse events recordable via hub API and CLI (T04, 2026-07-08 — live-verified); `report reuse` aggregation is T05
- [ ] R-axis evidence rules for observed reuse documented in the maturity standard - [ ] R-axis evidence rules for observed reuse documented in the maturity standard
- [x] Hub freshness visible (`composed_at`, stale flag) in API and stats (T02, 2026-07-07) - [x] Hub freshness visible (`composed_at`, stale flag) in API and stats (T02, 2026-07-07)