Skip to content

Commit 021c078

Browse files
solomonneasclaudecodex
authored
feat(receipts): archive verification evidence before retention pruning (#598)
* 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> * fix(receipts): fail closed on unsafe archives Co-Authored-By: Codex <codex@openai.com> --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Codex <codex@openai.com>
1 parent b966152 commit 021c078

6 files changed

Lines changed: 807 additions & 7 deletions

File tree

CHANGELOG.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,23 @@ 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. Archive paths that overlap the
21+
local run root, partial or symlinked archive destinations, and source trees
22+
containing symlinks or special files are rejected without pruning. New
23+
`.brigade/config.json`
24+
keys: `verify_runs_keep` (default 50), `verify_archive_enabled` (default
25+
true), and `verify_archive_dir` (default `.brigade/work/verify-archive`).
26+
1027
### Removed
1128
- Removed the opt-in `brigade run --deliberate` grounded-deliberation mode
1229
(planner, `brigade.deliberation.v1` artifact emission, and related runs

docs/receipt-schemas.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,44 @@ 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. The archive root must not overlap the local verify-runs root in either direction,
128+
including through a symlink alias. Source trees containing symlinks or special files
129+
are kept locally. An existing archive destination is reused only when it is a regular
130+
directory with the same files and file hashes as the source.
131+
132+
| Field | Type | Required | Notes |
133+
| --- | --- | --- | --- |
134+
| `schema` | string | yes | Always `brigade.verify_archive_index.v1` |
135+
| `schema_version` | integer | yes | Always `1` for this contract |
136+
| `run_id` | string | yes | Run directory name that was archived |
137+
| `archived_at` | string (ISO-8601) | yes | When the archival completed |
138+
| `source_run_dir` | string | yes | Original run directory path |
139+
| `archive_run_dir` | string | yes | Archived copy path |
140+
| `already_archived` | boolean | yes | `true` when an identical archive already existed |
141+
| `receipt_file_sha256` | string \| null | yes | SHA-256 of the archived `receipt.json` bytes; `null` when the run dir had no receipt |
142+
| `receipt_schema_version` | integer \| null | yes | The receipt's own `schema_version`; `null` for legacy receipts without one |
143+
| `receipt_sha256` | string \| null | yes | The receipt's self-declared canonical digest; `null` when absent |
144+
| `signature` | string \| null | yes | Receipt signature when the run was signed; `null` otherwise |
145+
| `key_id` | string \| null | yes | Signing key id paired with `signature`; `null` otherwise |
146+
| `status` | string \| null | yes | Receipt status at archival time |
147+
| `started_at` | string \| null | yes | Receipt start timestamp |
148+
| `completed_at` | string \| null | yes | Receipt completion timestamp |
149+
150+
---
151+
114152
## `brigade.work_closeout`: `schema_version: 1`
115153

116154
**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
)

0 commit comments

Comments
 (0)