feat(cache): add rebuild and provenance safeguards

Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a023c0-a0a3-7c03-b395-5a0d2757214d
This commit is contained in:
tegwick 2026-08-21 23:23:03 +02:00
parent bf3f416b82
commit 5dc2f3cdf6
11 changed files with 2809 additions and 4 deletions

View file

@ -47,7 +47,7 @@
| task | RMGR-WP-0005-T04 | progress | — | workplans/RMGR-WP-0005-registrar-consolidation-deterministic-ids.md |
| task | RMGR-WP-0005-T05 | wait | — | workplans/RMGR-WP-0005-registrar-consolidation-deterministic-ids.md |
| task | RMGR-WP-0005-T06 | done | — | workplans/RMGR-WP-0005-registrar-consolidation-deterministic-ids.md |
| task | RMGR-WP-0005-T07 | wait | — | workplans/RMGR-WP-0005-registrar-consolidation-deterministic-ids.md |
| task | RMGR-WP-0005-T07 | progress | — | workplans/RMGR-WP-0005-registrar-consolidation-deterministic-ids.md |
| task | RMGR-WP-0005-T08 | done | — | workplans/RMGR-WP-0005-registrar-consolidation-deterministic-ids.md |
| task | RMGR-WP-0005-T09 | done | — | workplans/RMGR-WP-0005-registrar-consolidation-deterministic-ids.md |
| task | RMGR-WP-0005-T10 | done | — | workplans/RMGR-WP-0005-registrar-consolidation-deterministic-ids.md |

62
docs/cache-rebuild_v1.md Normal file
View file

@ -0,0 +1,62 @@
---
id: RMGR-RUNBOOK-CACHE-0001
type: runbook
title: "Repository projection cache rebuild"
version: "1"
status: active
created: "2026-08-21"
updated: "2026-08-21"
workplan_task: RMGR-WP-0005-T07
---
# Repository projection cache rebuild v1
Repo Manager's local index is advisory. Repository files and Git history are
authoritative; deleting `.repo-manager/index.json` cannot delete work.
## Inspect and rebuild
```bash
rmgr cache status --path /path/to/repository
rmgr cache rebuild --path /path/to/repository --slug repository-slug
```
Status always reports `advisory`, `observed_at`, `age_seconds`, the indexed and
current source fingerprints, and explicit staleness reasons. Legacy indexes
without a fingerprint are stale. A source file changing after observation is
stale. Rebuild parses the files afresh and atomically replaces only the local
JSON projection.
The fingerprint covers workplans, intake/decision record locations, registers,
classification, and the repository intent/goal file. It hashes relative paths
and bytes so uncommitted authoritative changes are visible even when Git HEAD
has not moved.
## State Hub cache replacement gate
Before replacing a legacy State Hub database, export the closed unbound
workplan provenance:
```bash
rmgr cache export-closed \
--api-base http://127.0.0.1:8000 \
--output closed-workplan-provenance.json
```
The export is minimized to identity, repository, title, lifecycle, owner, and
creation/update timestamps. It includes a canonical UTC generation timestamp
and SHA-256 over canonical row JSON. The command refuses to overwrite an
existing export unless `--force` is supplied.
A database replacement is not authorized by this command. Before replacement:
1. verify the export count and `rows_sha256`;
2. prove that no live unbound file-derived records remain;
3. prove that hub-native records have reached their one authoritative central
owner, or retain the old database;
4. rebuild file-derived records into an isolated database and compare counts,
identifiers, statuses, and source provenance;
5. switch the local cache only after the comparison passes.
The old database remains a recoverable source until those gates pass. Repo
Manager does not merge database-local file-derived state back into repositories.

File diff suppressed because it is too large Load diff

View file

@ -48,6 +48,11 @@ used for the repair because it aborts while refreshing the brief for stale
registered path `/home/worsch/inter-hub`; the targeted binding route avoided
coupling this data repair to that unrelated registry defect.
The 217 closed rows were subsequently exported to
`docs/evidence/RMGR-WP-0005-closed-provenance-2026-08-21.json`. The minimized
export is 90,458 bytes and seals the canonical row payload with SHA-256
`40f227ecb138880d41cebf30a1f0559e709ab2d753be892409ea8e00f96d37d7`.
## Reproduction
The counts came from `GET /workplans/`, selecting rows whose

View file

@ -62,3 +62,8 @@ records require both repository and domain context and resolve to
types resolve to their one declared hub owner. Unknown types and writes whose
claimed owner disagrees with the contract are rejected instead of falling back
to whichever hub received the request.
Local repository projections implement the cache side through `rmgr cache
status|rebuild`; every read carries observation age and fingerprint-based
staleness. The guarded legacy-cache procedure and closed provenance export are
defined in `docs/cache-rebuild_v1.md`.

156
src/repo_manager/cache.py Normal file
View file

@ -0,0 +1,156 @@
"""Rebuildable local-cache status and legacy provenance export."""
from __future__ import annotations
import hashlib
import json
from datetime import datetime
from pathlib import Path
from typing import Any
from urllib.request import urlopen
from repo_manager.gitops import head_sha
from repo_manager.index_store import default_index_path, load_index
from repo_manager.parse.record import iter_record_files
from repo_manager.parse.register import iter_register_files
from repo_manager.parse.workplan import iter_workplan_files
from repo_manager.time import utc_now, utc_now_text
CACHE_STATUS_SCHEMA = "repo-manager.cache-status.v1"
CLOSED_EXPORT_SCHEMA = "repo-manager.closed-workplan-provenance.v1"
def authoritative_source_files(repo_root: Path) -> list[Path]:
repo_root = repo_root.resolve()
paths = {
*iter_workplan_files(repo_root),
*iter_record_files(repo_root),
*iter_register_files(repo_root),
}
for name in (".repo-classification.yaml", "INTENT.md", "GOAL.md"):
path = repo_root / name
if path.is_file():
paths.add(path)
return sorted(paths, key=lambda path: str(path.relative_to(repo_root)))
def source_fingerprint(repo_root: Path) -> tuple[str, list[str]]:
"""Hash the paths and bytes that feed the local repository projection."""
repo_root = repo_root.resolve()
digest = hashlib.sha256()
relative_paths: list[str] = []
for path in authoritative_source_files(repo_root):
relative = str(path.relative_to(repo_root))
relative_paths.append(relative)
digest.update(relative.encode("utf-8"))
digest.update(b"\0")
digest.update(path.read_bytes())
digest.update(b"\0")
return digest.hexdigest(), relative_paths
def _parse_timestamp(value: str) -> datetime:
parsed = datetime.fromisoformat(value)
if parsed.tzinfo is None:
raise ValueError("cache observed_at must be timezone-aware")
return parsed
def cache_status(repo_root: Path) -> dict[str, Any]:
repo_root = repo_root.resolve()
path = default_index_path(repo_root)
checked_at = utc_now()
if not path.is_file():
return {
"schema": CACHE_STATUS_SCHEMA,
"ok": True,
"advisory": True,
"cache_present": False,
"stale": True,
"stale_reasons": ["cache_missing"],
"checked_at": checked_at.isoformat().replace("+00:00", "Z"),
"repo_root": str(repo_root),
"index_path": str(path),
}
index = load_index(path)
fingerprint, source_files = source_fingerprint(repo_root)
stale_reasons: list[str] = []
if not index.source_fingerprint:
stale_reasons.append("legacy_index_missing_source_fingerprint")
elif index.source_fingerprint != fingerprint:
stale_reasons.append("authoritative_source_changed")
try:
observed_at = _parse_timestamp(index.observed_at)
age_seconds = max(0, int((checked_at - observed_at).total_seconds()))
except (TypeError, ValueError):
observed_at = None
age_seconds = None
stale_reasons.append("invalid_observed_at")
return {
"schema": CACHE_STATUS_SCHEMA,
"ok": True,
"advisory": True,
"cache_present": True,
"stale": bool(stale_reasons),
"stale_reasons": stale_reasons,
"checked_at": checked_at.isoformat().replace("+00:00", "Z"),
"observed_at": index.observed_at,
"age_seconds": age_seconds,
"repo_root": str(repo_root),
"index_path": str(path),
"indexed_head_sha": index.head_sha,
"current_head_sha": head_sha(repo_root),
"source_fingerprint": index.source_fingerprint,
"current_source_fingerprint": fingerprint,
"source_file_count": len(source_files),
"record_count": len(index.work_records),
}
def build_closed_provenance_export(
workplans: list[dict[str, Any]],
repos: list[dict[str, Any]],
*,
source: str,
) -> dict[str, Any]:
repo_slugs = {str(repo["id"]): repo.get("slug") for repo in repos if repo.get("id")}
rows = [
{
"id": row.get("id"),
"repo_id": row.get("repo_id"),
"repo_slug": repo_slugs.get(str(row.get("repo_id"))),
"slug": row.get("slug"),
"title": row.get("title"),
"status": row.get("status"),
"owner": row.get("owner"),
"created_at": row.get("created_at"),
"updated_at": row.get("updated_at"),
}
for row in workplans
if row.get("status") in {"finished", "archived"} and not row.get("backing_filename")
]
rows.sort(key=lambda row: (str(row["status"]), str(row["repo_slug"]), str(row["slug"])))
canonical_rows = json.dumps(rows, sort_keys=True, separators=(",", ":")).encode("utf-8")
return {
"schema": CLOSED_EXPORT_SCHEMA,
"ok": True,
"generated_at": utc_now_text(),
"source": source,
"filter": "backing_filename is null and status in [finished, archived]",
"count": len(rows),
"rows_sha256": hashlib.sha256(canonical_rows).hexdigest(),
"rows": rows,
}
def fetch_closed_provenance(api_base: str) -> dict[str, Any]:
base = api_base.rstrip("/")
with urlopen(f"{base}/workplans/", timeout=30) as response:
workplans = json.load(response)
with urlopen(f"{base}/repos/?limit=500", timeout=30) as response:
repos = json.load(response)
if not isinstance(workplans, list) or not isinstance(repos, list):
raise TypeError("State Hub returned a non-list workplan or repository payload")
return build_closed_provenance_export(workplans, repos, source=base)

View file

@ -314,6 +314,24 @@ def main(argv: list[str] | None = None) -> int:
p_authority_route.add_argument("--domain-slug", default=None)
p_authority_route.add_argument("--claimed-owner", default=None)
p_cache = sub.add_parser("cache", help="Inspect and rebuild advisory repository caches")
cache_sub = p_cache.add_subparsers(dest="cache_command")
p_cache_status = cache_sub.add_parser("status", help="Report cache age and source drift")
p_cache_status.add_argument("--path", default=".")
p_cache_rebuild = cache_sub.add_parser("rebuild", help="Rebuild the local index from files")
p_cache_rebuild.add_argument("--path", default=".")
p_cache_rebuild.add_argument("--slug", default=None)
p_cache_export = cache_sub.add_parser(
"export-closed",
help="Export unbound closed State Hub records before cache replacement",
)
p_cache_export.add_argument(
"--api-base",
default=os.environ.get("STATE_HUB_API_BASE", "http://127.0.0.1:8000"),
)
p_cache_export.add_argument("--output", required=True)
p_cache_export.add_argument("--force", action="store_true")
args = parser.parse_args(argv)
if args.version or args.command in (None, "version"):
@ -687,6 +705,49 @@ def main(argv: list[str] | None = None) -> int:
print(json.dumps({"ok": True, **result}, indent=2))
return 0
if args.command == "cache":
if not args.cache_command:
p_cache.print_help()
return 2
from repo_manager.cache import cache_status, fetch_closed_provenance
if args.cache_command == "status":
result = cache_status(Path(args.path))
elif args.cache_command == "rebuild":
from repo_manager.index_store import save_index
from repo_manager.observe import observe_repository
snapshot, index = observe_repository(Path(args.path), slug=args.slug)
index_path = save_index(index)
result = {
"ok": True,
"rebuilt": True,
"index_path": str(index_path),
"record_count": snapshot["index"]["record_count"],
"cache": cache_status(Path(args.path)),
}
else:
output = Path(args.output)
if output.exists() and not args.force:
print(
json.dumps(
{"ok": False, "error": f"output exists: {output}; use --force to replace"},
indent=2,
)
)
return 1
try:
result = fetch_closed_provenance(args.api_base)
except (OSError, TypeError, ValueError) as exc:
print(json.dumps({"ok": False, "error": str(exc)}, indent=2))
return 1
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(json.dumps(result, indent=2) + "\n", encoding="utf-8")
result = {key: value for key, value in result.items() if key != "rows"}
result["output"] = str(output)
print(json.dumps(result, indent=2))
return 0 if result.get("ok") else 1
parser.print_help()
return 0

View file

@ -3,6 +3,8 @@
from __future__ import annotations
import json
import os
import tempfile
from dataclasses import asdict, dataclass, field
from pathlib import Path
from typing import Any
@ -32,9 +34,11 @@ class RepoIndex:
repo_root: str
head_sha: str | None
observed_at: str
source_fingerprint: str | None = None
source_files: list[str] = field(default_factory=list)
work_records: list[WorkRecordEntry] = field(default_factory=list)
events: list[dict[str, Any]] = field(default_factory=list)
schema: str = "repo_manager.index.v0"
schema: str = "repo_manager.index.v1"
def to_dict(self) -> dict[str, Any]:
return {
@ -43,6 +47,8 @@ class RepoIndex:
"repo_root": self.repo_root,
"head_sha": self.head_sha,
"observed_at": self.observed_at,
"source_fingerprint": self.source_fingerprint,
"source_files": self.source_files,
"work_records": [asdict(r) for r in self.work_records],
"events": self.events,
}
@ -58,6 +64,8 @@ class RepoIndex:
repo_root=data["repo_root"],
head_sha=data.get("head_sha"),
observed_at=data.get("observed_at") or _now(),
source_fingerprint=data.get("source_fingerprint"),
source_files=list(data.get("source_files") or []),
work_records=records,
events=list(data.get("events") or []),
schema=data.get("schema") or "repo_manager.index.v0",
@ -71,7 +79,24 @@ def default_index_path(repo_root: Path) -> Path:
def save_index(index: RepoIndex, path: Path | None = None) -> Path:
path = path or default_index_path(Path(index.repo_root))
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(index.to_dict(), indent=2) + "\n", encoding="utf-8")
temporary: Path | None = None
try:
with tempfile.NamedTemporaryFile(
mode="w",
encoding="utf-8",
dir=path.parent,
prefix=f".{path.name}.",
delete=False,
) as handle:
temporary = Path(handle.name)
handle.write(json.dumps(index.to_dict(), indent=2) + "\n")
handle.flush()
os.fsync(handle.fileno())
os.replace(temporary, path)
temporary = None
finally:
if temporary is not None:
temporary.unlink(missing_ok=True)
return path

View file

@ -7,6 +7,7 @@ from pathlib import Path
import yaml
from repo_manager.cache import source_fingerprint
from repo_manager.classification import require_valid_classification
from repo_manager.gitops import head_sha, is_git_repo
from repo_manager.index_store import RepoIndex, WorkRecordEntry, _now
@ -117,11 +118,14 @@ def observe_repository(repo_root: Path, *, slug: str | None = None) -> tuple[dic
)
sha = head_sha(repo_root) if is_git_repo(repo_root) else None
fingerprint, source_files = source_fingerprint(repo_root)
index = RepoIndex(
slug=slug,
repo_root=str(repo_root),
head_sha=sha,
observed_at=_now(),
source_fingerprint=fingerprint,
source_files=source_files,
work_records=records,
)

71
tests/test_cache.py Normal file
View file

@ -0,0 +1,71 @@
from __future__ import annotations
import subprocess
from pathlib import Path
from repo_manager.cache import build_closed_provenance_export, cache_status
from repo_manager.index_store import save_index
from repo_manager.observe import observe_repository
def _git(repo: Path, *args: str) -> None:
subprocess.run(["git", *args], cwd=repo, check=True, capture_output=True)
def _repo(tmp_path: Path) -> Path:
repo = tmp_path / "example"
(repo / "workplans").mkdir(parents=True)
_git(repo, "init")
_git(repo, "config", "user.email", "test@example.com")
_git(repo, "config", "user.name", "Test")
(repo / "workplans" / "EX-WP-0001.md").write_text(
"---\nid: EX-WP-0001\ntitle: Example\nstatus: active\n---\n",
encoding="utf-8",
)
_git(repo, "add", ".")
_git(repo, "commit", "-m", "seed")
return repo
def test_cache_status_reports_age_and_detects_source_drift(tmp_path: Path) -> None:
repo = _repo(tmp_path)
missing = cache_status(repo)
assert missing["cache_present"] is False
assert missing["stale_reasons"] == ["cache_missing"]
_snapshot, index = observe_repository(repo)
save_index(index)
fresh = cache_status(repo)
assert fresh["advisory"] is True
assert fresh["stale"] is False
assert fresh["age_seconds"] >= 0
path = repo / "workplans" / "EX-WP-0001.md"
path.write_text(path.read_text(encoding="utf-8") + "\nChanged.\n", encoding="utf-8")
stale = cache_status(repo)
assert stale["stale"] is True
assert stale["stale_reasons"] == ["authoritative_source_changed"]
def test_closed_export_excludes_live_and_bound_rows() -> None:
workplans = [
{"id": "1", "repo_id": "r1", "slug": "closed", "status": "finished"},
{"id": "2", "repo_id": "r1", "slug": "live", "status": "active"},
{
"id": "3",
"repo_id": "r1",
"slug": "bound",
"status": "archived",
"backing_filename": "bound.md",
},
]
result = build_closed_provenance_export(
workplans,
[{"id": "r1", "slug": "example"}],
source="http://state-hub.test",
)
assert result["count"] == 1
assert result["rows"][0]["slug"] == "closed"
assert result["rows"][0]["repo_slug"] == "example"
assert len(result["rows_sha256"]) == 64

View file

@ -378,7 +378,7 @@ byte-identical writeback, and neither creates a duplicate record.
```task
id: RMGR-WP-0005-T07
status: wait
status: progress
priority: high
state_hub_task_id: "70f83359-0b61-4dc0-83b0-33f289b64e83"
```
@ -396,6 +396,24 @@ Measured 2026-08-17: 955 workplans locally against 649 on the primary, 320
local-only, of which **288 are backed by files that all exist on disk**. That
portion of the divergence is redundant and needs no merge — only a rebuild.
**Progress (2026-08-21):** `rmgr cache status|rebuild` now makes the local
repository projection explicitly advisory and reports canonical observation
time, age, source fingerprint, current/indexed Git revision, and concrete stale
reasons. The fingerprint covers the authoritative record/classification/intent
bytes, including uncommitted changes. A live drill correctly rejected the
legacy index as stale, rebuilt 73 records from repository files, and immediately
reported a fresh cache.
The 217 closed unbound rows are preserved in a minimized, integrity-sealed
export at
`docs/evidence/RMGR-WP-0005-closed-provenance-2026-08-21.json`; live unbound
rows remain zero. `docs/cache-rebuild_v1.md` gates any database replacement on
an isolated projection rebuild and proof that hub-native records reached their
one central owner. Remaining before `done`: compare against an isolated rebuilt
State Hub database and verify hub-native centralization. The documented remote
tunnel at `127.0.0.1:18000` was not running during this pass, so no central
comparison was inferred.
## Separate file-derived from hub-native data
```task