Invalidate the composed index when a registration changes (REUSE-WP-0020-T09)
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
ci / validate-registry (push) Successful in 1m18s
Build and Publish Container Image / build-and-push (push) Successful in 23s

Enabling a federation source left /v1/federated serving its cached compose and
reporting stale: false while doing it, so a repo could be correctly registered
and silently invisible for as long as its cached index survived. That is how
evidence-binder stayed missing after re-enabling until a manual
POST /v1/federated/compose was issued.

Registration writes now mark the composed index stale, and a plain GET
recomposes when the flag is set. Clearing it there is not a silent clear: that
pass really did refetch. A PATCH touching only a description does not
invalidate anything.

This changes a contract documented in specs/FederationHubAPI.md, so the
staleness section is rewritten rather than left to drift, including the two
triggers that now set the flag.

The first two tests written for this were worthless -- they passed with the fix
removed, because a newly registered repo has no cache entry and gets fetched
regardless. The real failure needs a populated cache holding stale content
inside its 24h TTL. test_re_enabled_source_refetches_a_stale_cache models that
and fails on pre-fix code; verified by reverting the mark_stale calls.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
tegwick 2026-08-21 13:51:58 +02:00
parent d1de320743
commit 6cbc862371
5 changed files with 235 additions and 26 deletions

View file

@ -208,8 +208,12 @@ def test_get_federated_reports_stale_after_mark(hub_client, monkeypatch):
db_path = os.environ["REUSE_SURFACE_DB"]
_HubStore(Path(db_path)).mark_stale()
response = hub_client.get("/v1/federated")
assert response.json()["stale"] is True
# A stale index recomposes on the next plain GET and the flag clears --
# not a silent clear, since that pass refetched (REUSE-WP-0020-T09).
with patch("urllib.request.urlopen", return_value=FakeResponse()):
response = hub_client.get("/v1/federated")
assert response.json()["stale"] is False
assert _HubStore(Path(db_path)).get_compose_state()["stale"] is False
# --- T02: Forgejo webhook receiver ---
@ -393,4 +397,154 @@ def test_store_record_reuse_event_and_list(tmp_path):
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"})
store.record_reuse_event({**VALID_REUSE_EVENT, "verdict": "nope"})
# --- T09: a registration change invalidates the composed index ---
SECOND_INDEX_BEFORE = """
version: 1
domain: helix_forge
updated: "2026-08-20"
capabilities: []
"""
SECOND_INDEX_AFTER = """
version: 1
domain: helix_forge
updated: "2026-08-21"
capabilities:
- id: capability.remote.second
name: Second Remote
domain: helix_forge
vector: D3/A2/C3/R3
owner: example
path: registry/capabilities/capability.remote.second.md
summary: Capability a member repo published after its index was repaired
tags: [sample]
consumption_modes: [planning]
"""
SECOND_URL = "https://example.com/second.yaml"
class _Forge:
"""Serves a per-URL index body that the test can change mid-flight."""
def __init__(self):
self.bodies = {
"https://example.com/capabilities.yaml": REMOTE_INDEX,
SECOND_URL: SECOND_INDEX_BEFORE,
}
def publish(self, url, body):
self.bodies[url] = body
def open(self, request, *args, **kwargs):
url = request.full_url if hasattr(request, "full_url") else str(request)
body = self.bodies[url].encode("utf-8")
class FakeResponse:
def __enter__(self_inner):
return self_inner
def __exit__(self_inner, *args):
return False
def read(self_inner):
return body
return FakeResponse()
def _register(hub_client, repo, url):
return hub_client.post(
"/v1/repos",
json={"repo": repo, "url": url, "domain": "helix_forge"},
headers={"Authorization": "Bearer test-token"},
)
def _set_enabled(hub_client, repo, value):
return hub_client.patch(
f"/v1/repos/{repo}",
json={"enabled": value},
headers={"Authorization": "Bearer test-token"},
)
def test_re_enabled_source_refetches_a_stale_cache(hub_client):
"""The evidence-binder incident, reproduced.
A member's cached index is the broken version. The member repairs and
republishes, and the source is re-enabled -- but the per-source cache is
still inside its 24h TTL, so a plain GET would keep serving the old empty
copy and report stale: false while doing it. The registration change must
force a refetch.
"""
forge = _Forge()
_register(hub_client, "remote-repo", "https://example.com/capabilities.yaml")
_register(hub_client, "second-repo", SECOND_URL)
with patch("urllib.request.urlopen", side_effect=forge.open):
# cache both sources while second-repo still publishes nothing usable
hub_client.get("/v1/federated?refresh=true")
_set_enabled(hub_client, "second-repo", False)
hub_client.get("/v1/federated?refresh=true")
# the member repairs its index, then asks to be re-enabled
forge.publish(SECOND_URL, SECOND_INDEX_AFTER)
_set_enabled(hub_client, "second-repo", True)
response = hub_client.get("/v1/federated")
ids = {c["id"] for c in response.json()["capabilities"]}
assert "capability.remote.second" in ids, (
"re-enabled source served from a stale cache -- registered but invisible"
)
assert response.json()["stale"] is False
def test_registration_write_marks_the_index_stale(hub_client):
"""A registration write must flag the composed index as no longer current.
Asserted on the flag rather than on composed_at, which has second
resolution and ties when two composes land in the same second.
"""
from reuse_surface.hub.store import HubStore as _HubStore
store = _HubStore(Path(os.environ["REUSE_SURFACE_DB"]))
forge = _Forge()
_register(hub_client, "remote-repo", "https://example.com/capabilities.yaml")
with patch("urllib.request.urlopen", side_effect=forge.open):
hub_client.get("/v1/federated?refresh=true")
assert store.get_compose_state()["stale"] is False
_register(hub_client, "second-repo", SECOND_URL)
assert store.get_compose_state()["stale"] is True, (
"registering a repo left the composed index looking current"
)
hub_client.get("/v1/federated")
assert store.get_compose_state()["stale"] is False
def test_cosmetic_patch_does_not_force_a_recompose(hub_client):
"""A description edit changes nothing about composition; leave the cache alone."""
forge = _Forge()
_register(hub_client, "remote-repo", "https://example.com/capabilities.yaml")
with patch("urllib.request.urlopen", side_effect=forge.open):
first = hub_client.get("/v1/federated?refresh=true")
composed_at = first.json()["composed_at"]
hub_client.patch(
"/v1/repos/remote-repo",
json={"description": "just a label change"},
headers={"Authorization": "Bearer test-token"},
)
after = hub_client.get("/v1/federated")
assert after.json()["composed_at"] == composed_at
assert after.json()["stale"] is False