Skip to content

Commit b7fd7cd

Browse files
solomonneasclaude
andcommitted
feat(receipts): archive verification evidence before retention pruning
Verification retention kept the newest 50 run directories and deleted older receipt evidence, which conflicts with append-only audit storage. Add an archival path that preserves receipt evidence before local pruning runs, carrying integrity metadata and schema version through the archive, and keep the local retention limit configurable. Closes #565 Co-authored-by: Claude <noreply@anthropic.com>
1 parent b966152 commit b7fd7cd

6 files changed

Lines changed: 621 additions & 7 deletions

File tree

CHANGELOG.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
- Verify-run retention now archives receipt evidence before pruning (#565).
12+
When `.brigade/work/verify-runs/` grows past the retention cap, each run
13+
directory is copied into the verify archive (default
14+
`.brigade/work/verify-archive/<run-id>/`) and an append-only
15+
`index.jsonl` entry (`brigade.verify_archive_index.v1`) records the
16+
receipt's digest, signature, key id, and schema version before the local
17+
copy is deleted. Archival re-verifies the copied receipt bytes and the
18+
receipt's self-declared `digests.receipt_sha256`; a run directory whose
19+
archival fails or whose receipt no longer re-hashes is kept locally, so
20+
pruning never destroys unpreserved evidence. New `.brigade/config.json`
21+
keys: `verify_runs_keep` (default 50), `verify_archive_enabled` (default
22+
true), and `verify_archive_dir` (default `.brigade/work/verify-archive`).
23+
1024
### Removed
1125
- Removed the opt-in `brigade run --deliberate` grounded-deliberation mode
1226
(planner, `brigade.deliberation.v1` artifact emission, and related runs

docs/receipt-schemas.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,41 @@ receipts or routing authority.
111111

112112
---
113113

114+
## `brigade.verify_archive_index.v1`: `schema_version: 1`
115+
116+
**Path:** `<verify-archive-root>/index.jsonl` (one JSON object per line, append-only,
117+
sorted keys per line). The default archive root is `.brigade/work/verify-archive`;
118+
`.brigade/config.json` keys `verify_archive_enabled` and `verify_archive_dir` override it.
119+
120+
Retention prunes the local `.brigade/work/verify-runs/` directory down to the newest
121+
`verify_runs_keep` runs (default 50). Before any run directory is deleted it is copied
122+
into the archive root as `<verify-archive-root>/<run-id>/` and one index line is
123+
appended. A run directory whose archival fails is kept locally, so pruning never
124+
destroys receipt evidence that was not preserved first. Archival re-checks integrity
125+
both ways: the archived `receipt.json` bytes must hash to the source bytes, and a
126+
receipt carrying `digests.receipt_sha256` must still re-hash to that value after the
127+
copy.
128+
129+
| Field | Type | Required | Notes |
130+
| --- | --- | --- | --- |
131+
| `schema` | string | yes | Always `brigade.verify_archive_index.v1` |
132+
| `schema_version` | integer | yes | Always `1` for this contract |
133+
| `run_id` | string | yes | Run directory name that was archived |
134+
| `archived_at` | string (ISO-8601) | yes | When the archival completed |
135+
| `source_run_dir` | string | yes | Original run directory path |
136+
| `archive_run_dir` | string | yes | Archived copy path |
137+
| `already_archived` | boolean | yes | `true` when an identical archive already existed |
138+
| `receipt_file_sha256` | string \| null | yes | SHA-256 of the archived `receipt.json` bytes; `null` when the run dir had no receipt |
139+
| `receipt_schema_version` | integer \| null | yes | The receipt's own `schema_version`; `null` for legacy receipts without one |
140+
| `receipt_sha256` | string \| null | yes | The receipt's self-declared canonical digest; `null` when absent |
141+
| `signature` | string \| null | yes | Receipt signature when the run was signed; `null` otherwise |
142+
| `key_id` | string \| null | yes | Signing key id paired with `signature`; `null` otherwise |
143+
| `status` | string \| null | yes | Receipt status at archival time |
144+
| `started_at` | string \| null | yes | Receipt start timestamp |
145+
| `completed_at` | string \| null | yes | Receipt completion timestamp |
146+
147+
---
148+
114149
## `brigade.work_closeout`: `schema_version: 1`
115150

116151
**Path:** `.brigade/work/closeouts/<closeout-id>/closeout.json`

src/brigade/config.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@
1818
DEFAULT_GRAPHTRAIL_DELTA_TIMEOUT_SECONDS = 10.0
1919
CAPTURE_BEFORE_RETRY_MODES = ("warn", "block", "off")
2020
DEFAULT_CAPTURE_BEFORE_RETRY = "warn"
21+
DEFAULT_VERIFY_RUNS_KEEP = 50
22+
DEFAULT_VERIFY_ARCHIVE_ENABLED = True
23+
DEFAULT_VERIFY_ARCHIVE_DIR = ".brigade/work/verify-archive"
2124

2225

2326
@dataclass
@@ -26,6 +29,9 @@ class Config:
2629
selection: Selection
2730
graphtrail_delta_timeout_seconds: float = DEFAULT_GRAPHTRAIL_DELTA_TIMEOUT_SECONDS
2831
capture_before_retry: str = DEFAULT_CAPTURE_BEFORE_RETRY
32+
verify_runs_keep: int = DEFAULT_VERIFY_RUNS_KEEP
33+
verify_archive_enabled: bool = DEFAULT_VERIFY_ARCHIVE_ENABLED
34+
verify_archive_dir: str = DEFAULT_VERIFY_ARCHIVE_DIR
2935

3036

3137
def validate_graphtrail_delta_timeout(value: Any) -> float:
@@ -65,6 +71,42 @@ def resolve_graphtrail_delta_timeout(target: Path, cli_override: float | None =
6571
return DEFAULT_GRAPHTRAIL_DELTA_TIMEOUT_SECONDS
6672

6773

74+
def validate_verify_runs_keep(value: Any) -> int:
75+
if isinstance(value, bool) or not isinstance(value, int) or value < 1:
76+
raise ValueError("verify_runs_keep must be a positive integer")
77+
return value
78+
79+
80+
def validate_verify_archive_enabled(value: Any) -> bool:
81+
if not isinstance(value, bool):
82+
raise ValueError("verify_archive_enabled must be true or false")
83+
return value
84+
85+
86+
def validate_verify_archive_dir(value: Any) -> str:
87+
if not isinstance(value, str) or not value.strip():
88+
raise ValueError("verify_archive_dir must be a non-empty string")
89+
return value.strip()
90+
91+
92+
def resolve_verify_runs_keep(target: Path) -> int:
93+
cfg = load_config(target)
94+
if cfg is not None:
95+
return cfg.verify_runs_keep
96+
return DEFAULT_VERIFY_RUNS_KEEP
97+
98+
99+
def resolve_verify_archive(target: Path) -> tuple[bool, Path]:
100+
"""Return (enabled, archive root) for verify-run evidence archival."""
101+
cfg = load_config(target)
102+
enabled = cfg.verify_archive_enabled if cfg is not None else DEFAULT_VERIFY_ARCHIVE_ENABLED
103+
raw = cfg.verify_archive_dir if cfg is not None else DEFAULT_VERIFY_ARCHIVE_DIR
104+
path = Path(raw).expanduser()
105+
if not path.is_absolute():
106+
path = target / path
107+
return enabled, path
108+
109+
68110
def config_path(target: Path) -> Path:
69111
return target / CONFIG_REL_PATH
70112

@@ -86,6 +128,15 @@ def write_config(target: Path, cfg: Config) -> None:
86128
capture_before_retry = validate_capture_before_retry(cfg.capture_before_retry)
87129
if capture_before_retry != DEFAULT_CAPTURE_BEFORE_RETRY:
88130
payload["capture_before_retry"] = capture_before_retry
131+
verify_runs_keep = validate_verify_runs_keep(cfg.verify_runs_keep)
132+
if verify_runs_keep != DEFAULT_VERIFY_RUNS_KEEP:
133+
payload["verify_runs_keep"] = verify_runs_keep
134+
verify_archive_enabled = validate_verify_archive_enabled(cfg.verify_archive_enabled)
135+
if verify_archive_enabled != DEFAULT_VERIFY_ARCHIVE_ENABLED:
136+
payload["verify_archive_enabled"] = verify_archive_enabled
137+
verify_archive_dir = validate_verify_archive_dir(cfg.verify_archive_dir)
138+
if verify_archive_dir != DEFAULT_VERIFY_ARCHIVE_DIR:
139+
payload["verify_archive_dir"] = verify_archive_dir
89140
path.write_text(json.dumps(payload, indent=2) + "\n")
90141

91142

@@ -113,9 +164,17 @@ def load_config(target: Path) -> Optional[Config]:
113164
timeout_raw = data.get("graphtrail_delta_timeout_seconds", DEFAULT_GRAPHTRAIL_DELTA_TIMEOUT_SECONDS)
114165
timeout = validate_graphtrail_delta_timeout(timeout_raw)
115166
capture_before_retry = validate_capture_before_retry(data.get("capture_before_retry", DEFAULT_CAPTURE_BEFORE_RETRY))
167+
verify_runs_keep = validate_verify_runs_keep(data.get("verify_runs_keep", DEFAULT_VERIFY_RUNS_KEEP))
168+
verify_archive_enabled = validate_verify_archive_enabled(
169+
data.get("verify_archive_enabled", DEFAULT_VERIFY_ARCHIVE_ENABLED)
170+
)
171+
verify_archive_dir = validate_verify_archive_dir(data.get("verify_archive_dir", DEFAULT_VERIFY_ARCHIVE_DIR))
116172
return Config(
117173
version=version,
118174
selection=sel,
119175
graphtrail_delta_timeout_seconds=timeout,
120176
capture_before_retry=capture_before_retry,
177+
verify_runs_keep=verify_runs_keep,
178+
verify_archive_enabled=verify_archive_enabled,
179+
verify_archive_dir=verify_archive_dir,
121180
)

src/brigade/work_cmd/verification.py

Lines changed: 139 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -429,22 +429,151 @@ def _safe_finalize_verify_receipt(
429429
return receipt, rc
430430

431431

432-
VERIFY_RUNS_KEEP = 50
432+
VERIFY_RUNS_KEEP = config.DEFAULT_VERIFY_RUNS_KEEP
433433

434+
VERIFY_ARCHIVE_INDEX_NAME = "index.jsonl"
435+
VERIFY_ARCHIVE_INDEX_SCHEMA = "brigade.verify_archive_index.v1"
436+
VERIFY_ARCHIVE_INDEX_SCHEMA_VERSION = 1
434437

435-
def _prune_verify_runs(target: Path, keep: int = VERIFY_RUNS_KEEP) -> int:
436-
"""Cap retained verify-run directories so receipts + raw logs don't grow without bound.
438+
439+
def _verify_archive_root(target: Path, archive_root: Path | None = None) -> Path | None:
440+
"""Resolve the verify-evidence archive root, or None when archival is disabled."""
441+
if archive_root is not None:
442+
return archive_root
443+
enabled, resolved = config.resolve_verify_archive(target)
444+
if not enabled:
445+
return None
446+
return resolved
447+
448+
449+
def _verify_archive_index_entry(
450+
run_dir: Path,
451+
dest: Path,
452+
receipt_file_sha256: str | None,
453+
*,
454+
already_archived: bool,
455+
) -> dict[str, Any]:
456+
receipt = _verify_read_receipt(run_dir)
457+
digests = receipt.get("digests") if receipt is not None and isinstance(receipt.get("digests"), dict) else {}
458+
return {
459+
"schema": VERIFY_ARCHIVE_INDEX_SCHEMA,
460+
"schema_version": VERIFY_ARCHIVE_INDEX_SCHEMA_VERSION,
461+
"run_id": run_dir.name,
462+
"archived_at": helpers._now().isoformat(),
463+
"source_run_dir": str(run_dir),
464+
"archive_run_dir": str(dest),
465+
"already_archived": already_archived,
466+
"receipt_file_sha256": receipt_file_sha256,
467+
"receipt_schema_version": receipt.get("schema_version") if receipt is not None else None,
468+
"receipt_sha256": digests.get("receipt_sha256"),
469+
"signature": digests.get("signature"),
470+
"key_id": digests.get("key_id"),
471+
"status": receipt.get("status") if receipt is not None else None,
472+
"started_at": receipt.get("started_at") if receipt is not None else None,
473+
"completed_at": receipt.get("completed_at") if receipt is not None else None,
474+
}
475+
476+
477+
def _append_verify_archive_index(archive_root: Path, entry: dict[str, Any]) -> None:
478+
index_path = archive_root / VERIFY_ARCHIVE_INDEX_NAME
479+
with index_path.open("a", encoding="utf-8") as handle:
480+
handle.write(json.dumps(entry, sort_keys=True, default=str) + "\n")
481+
482+
483+
def _assert_archived_receipt_integrity(archived_receipt: Path) -> None:
484+
"""Re-verify a copied receipt's self-declared digest against its archived bytes."""
485+
try:
486+
payload = json.loads(archived_receipt.read_text())
487+
except (OSError, json.JSONDecodeError) as exc:
488+
raise OSError(f"verify archive receipt is unreadable: {archived_receipt}: {exc}") from exc
489+
if not isinstance(payload, dict):
490+
raise OSError(f"verify archive receipt is not a JSON object: {archived_receipt}")
491+
digests = payload.get("digests")
492+
if not isinstance(digests, dict):
493+
return # legacy receipts without a digests block have no self-digest to re-verify
494+
expected = digests.get("receipt_sha256")
495+
if not isinstance(expected, str) or not expected:
496+
return
497+
actual = localio.canonical_json_digest(payload, exclude_keys={"digests"})
498+
if actual != expected:
499+
raise OSError(f"verify archive receipt digest mismatch: {archived_receipt}")
500+
501+
502+
def _archive_verify_run(run_dir: Path, archive_root: Path) -> dict[str, Any]:
503+
"""Copy one run dir into the archive and append an index entry. Raises on failure.
504+
505+
Integrity is checked twice: the archived receipt bytes must match the source
506+
bytes, and a receipt that carries ``digests.receipt_sha256`` must still
507+
re-hash to that value after the copy. Callers must treat any exception as
508+
"do not delete the original".
509+
"""
510+
run_id = run_dir.name
511+
dest = archive_root / run_id
512+
receipt_path = run_dir / "receipt.json"
513+
source_receipt_sha = localio.file_sha256(receipt_path) if receipt_path.is_file() else None
514+
if dest.exists():
515+
# Re-archive of the same run id is only safe when the evidence is identical.
516+
dest_receipt = dest / "receipt.json"
517+
dest_sha = localio.file_sha256(dest_receipt) if dest_receipt.is_file() else None
518+
if source_receipt_sha is None or dest_sha != source_receipt_sha:
519+
raise OSError(f"verify archive conflict: {dest} already exists with different evidence")
520+
entry = _verify_archive_index_entry(run_dir, dest, source_receipt_sha, already_archived=True)
521+
_append_verify_archive_index(archive_root, entry)
522+
return entry
523+
archive_root.mkdir(parents=True, exist_ok=True)
524+
staging = archive_root / f".{run_id}.staging-{uuid4().hex[:8]}"
525+
shutil.copytree(run_dir, staging)
526+
try:
527+
if source_receipt_sha is not None:
528+
copied_sha = localio.file_sha256(staging / "receipt.json")
529+
if copied_sha != source_receipt_sha:
530+
raise OSError(f"verify archive copy integrity check failed: {run_id}")
531+
_assert_archived_receipt_integrity(staging / "receipt.json")
532+
os.rename(staging, dest)
533+
except BaseException:
534+
shutil.rmtree(staging, ignore_errors=True)
535+
raise
536+
entry = _verify_archive_index_entry(run_dir, dest, source_receipt_sha, already_archived=False)
537+
_append_verify_archive_index(archive_root, entry)
538+
return entry
539+
540+
541+
def _prune_verify_runs(target: Path, keep: int | None = None, archive_root: Path | None = None) -> int:
542+
"""Cap retained verify-run directories, archiving receipt evidence before deletion.
437543
438544
Run dirs are timestamp-prefixed (sortable by name); the newest ``keep`` are
439-
retained and older ones removed. Best-effort: a removal error never aborts a
440-
verify run.
545+
retained locally. Older dirs are first copied into the verify archive (with
546+
an append-only index entry carrying the receipt digest, signature, and
547+
schema version) and only then removed, so pruning never destroys receipt
548+
evidence. A run dir whose archival fails is kept locally. Best-effort: an
549+
archival or removal error never aborts a verify run.
441550
"""
551+
if keep is None:
552+
try:
553+
keep = config.resolve_verify_runs_keep(target)
554+
except Exception:
555+
keep = VERIFY_RUNS_KEEP
442556
root = helpers._verify_runs_root(target)
443557
if not root.is_dir():
444558
return 0
559+
try:
560+
resolved_archive = _verify_archive_root(target, archive_root)
561+
except Exception:
562+
resolved_archive = None
445563
run_dirs = sorted((child for child in root.iterdir() if child.is_dir()), key=lambda p: p.name, reverse=True)
446564
removed = 0
447-
for stale in run_dirs[keep:]:
565+
# Oldest first so the append-only archive index grows in chronological order.
566+
for stale in reversed(run_dirs[keep:]):
567+
if resolved_archive is not None:
568+
try:
569+
resolved_archive.relative_to(stale)
570+
continue # the archive root itself lives under the runs root; never prune it
571+
except ValueError:
572+
pass
573+
try:
574+
_archive_verify_run(stale, resolved_archive)
575+
except Exception:
576+
continue # prune safety: never delete evidence that failed to archive
448577
try:
449578
shutil.rmtree(stale)
450579
removed += 1
@@ -1055,7 +1184,10 @@ def _write_reused_receipt(
10551184
receipt["digests"]["key_id"] = key_id
10561185
helpers._write_json(run_dir / "receipt.json", receipt)
10571186
_write_verify_markdown(run_dir, receipt)
1058-
_prune_verify_runs(target)
1187+
try:
1188+
_prune_verify_runs(target)
1189+
except Exception:
1190+
pass
10591191
return receipt, 0
10601192

10611193

0 commit comments

Comments
 (0)