Skip to content

test(backup): harden survivor-copy retention guard - #822

Merged
EtanHey merged 6 commits into
mainfrom
wt/laneb-retention-guard
Sep 9, 2026
Merged

test(backup): harden survivor-copy retention guard#822
EtanHey merged 6 commits into
mainfrom
wt/laneb-retention-guard

Conversation

@EtanHey

@EtanHey EtanHey commented Sep 9, 2026

Copy link
Copy Markdown
Owner

Summary

  • reject early-success, dead-branch, else-branch, and non-positive verified-upload bypasses
  • require the durable state write itself—not merely state construction—to precede deletion
  • pin both ends of fix(backup): never treat a file as covered without a surviving archive object (S) #815's md5 seam: persisted-state consumer, upload-response producer, and Drive field request
  • document the honest boundary: this is targeted structural checking, not general data-flow analysis

Why

Remote review demonstrated that a present-looking AST comparison could remain unreachable, bypassable, or permanently falsy while the original guard passed. This layer adds RED mutations for each reproduced bypass and preserves fail-closed behavior when the sibling backup_daily.py source is unavailable.

Verification

  • 23 focused tests passed
  • direct structural checker passed
  • canonical changed-only gate passed: 23 owned unit, 3 registration, 40 isolated routing, Bun, and FTS shell
  • Claude pair re-review: FINAL ACCEPT after independent empty-string, wrong-key, and producer-side variants
  • Codex re-review requested on head 2bcf4eff

Size: M

Stacked on the Lane B core guard PR. Do not merge before its base.

— brainlayerCodex-4c4386f6 (worker) · codex/gpt-5.6-sol


Note

Low Risk
Changes are limited to CI/static analysis and tests; production backup code is only read for AST checks, not modified in this diff.

Overview
Hardens the PR #815 static CI guard so substring-style AST checks cannot pass while coverage, verified-upload gating, or md5 handling is bypassed via dead branches, weakened conditions, or reordering.

The inspector now uses exact structural matching (false-rejection if shapes, single recorded_md5 assignment, allowed coverage returns) and ignores statically unreachable code. It also pins run_backup to a single top-level result["verified"] and upload gate immediately after bundle verification, requires _atomic_write_json persistence (not just state construction) before deletions, and extends the contract to backup_daily.upload_file_to_drive_raw (Drive fields must include md5Checksum, state must persist uploaded.get("md5Checksum")). _function lookup now requires a unique top-level definition; duplicates fail. The CLI fails closed if sibling backup_daily.py is missing.

Tests add RED mutation fixtures for each bypass class and wire the thin guard test through the main behavioral fixture.

Reviewed by Cursor Bugbot for commit 96e9afe. Bugbot is set up for automated code reviews on this repo. Configure here.

Note

Harden inspect_jsonl_retention_invariant with exact AST structural checks

  • Replaces loose substring checks in the retention guard with exact structural AST matching for recorded md5 loading, archive-inventory rejection, md5 equality, coverage returns, and verified-upload gate ordering
  • Adds helpers for statically dead branch detection (_inside_statically_dead_branch), reachability-aware comparison search (_has_reachable_compare), exact rejection-condition matching (_has_exact_false_rejection), and single-assignment validation (_has_exact_single_assignment)
  • Adds a cross-file contract: the guard now requires sibling backup_daily.py source and checks that the raw Drive upload request asks for md5Checksum and that the upload response md5 is persisted in durable state before deletion
  • Restricts _function helper to direct top-level definitions only, rejecting nested or duplicate definitions of required functions
  • Risk: the guard now exits with status 1 when backup_daily.py is missing alongside the JSONL backup module; any production AST that weakens required retention branches, duplicates definitions, or reorders persistence-before-deletion will now fail the CI guard

Macroscope summarized 96e9afe.

@coderabbitai

coderabbitai Bot commented Sep 9, 2026

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 2 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Advanced

Run ID: 452942c5-4995-4a1a-9e2f-e59bb3f95c73

📥 Commits

Reviewing files that changed from the base of the PR and between 5bb7b5b and 96e9afe.

📒 Files selected for processing (3)
  • src/brainlayer/backup_retention_invariant.py
  • tests/test_backup_retention_invariant.py
  • tests/test_jsonl_backup.py

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@EtanHey

EtanHey commented Sep 9, 2026

Copy link
Copy Markdown
Owner Author

@codex review

Please focus on whether this static guard actually fails when PR #815's survivor-copy evidence or deletion ordering is loosened, and whether deliberate refactors receive actionable guidance.

— brainlayerCodex-4c4386f6 (worker) · codex/gpt-5.6-sol

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 9, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-09T18:36:13.560497Z 2bcf4ef Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@EtanHey EtanHey added the size:S Tight-loop PR size: 51-150 hand-written lines changed label Sep 9, 2026
Comment thread src/brainlayer/backup_retention_invariant.py Outdated
Comment thread src/brainlayer/backup_retention_invariant.py Outdated
Comment thread src/brainlayer/backup_retention_invariant.py

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 692df53e3d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +42 to +48
def _has_compare(function: ast.FunctionDef, *, operator: type[ast.cmpop], terms: tuple[str, ...]) -> bool:
for node in ast.walk(function):
if not isinstance(node, ast.Compare) or not any(isinstance(op, operator) for op in node.ops):
continue
rendered = ast.unparse(node)
if all(term in rendered for term in terms):
return True

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Reject survivor checks that can be bypassed

If _state_matches gains an earlier coverage fast path—for example, widening if surviving_archives is None to also return true for matching mtimes—these comparisons remain somewhere in the AST, so _has_compare still accepts the file even though uploads can mark files covered without survivor identity, MD5, or source-digest evidence. Require the evidence checks to govern every successful return, and add a mutation covering an early bypass path.

AGENTS.md reference: AGENTS.md:L49-L52

Useful? React with 👍 / 👎.

Comment on lines +68 to +73
condition = ast.unparse(node.test)
if 'result["verified"]' not in condition and "result['verified']" not in condition:
continue
if "upload" not in condition:
continue
lines.update(child.lineno for child in ast.walk(node) if isinstance(child, ast.Call))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Require a positive verified-and-uploaded deletion gate

When the production condition is loosened to result["verified"] or upload or even not result["verified"] and upload, this helper still sees both substrings and treats every nested deletion as guarded; the static test therefore stays green while an unverified archive can be pruned and unlinked. Inspect the condition as a positive conjunction rather than matching rendered text, and mutation-test both polarity and andor.

AGENTS.md reference: AGENTS.md:L49-L52

Useful? React with 👍 / 👎.

elif not any(_passes_live_inventory(call) for call in selection_calls):
errors.append("run_backup must hand its live Drive inventory to candidate selection")

state_write_calls = _calls(run_backup, "_update_state_for_uploaded")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Order deletions after the actual state persistence

If a refactor computes uploaded_state = _update_state_for_uploaded(...) before pruning but moves _atomic_write_json(state_path, uploaded_state) after the prune/unlink block, this guard returns no violations because it compares deletion lines with the state-construction helper rather than the persistence call. That recreates the vulnerable deletion ordering while CI remains green, so trace the _atomic_write_json call that consumes the updated state and require that write to precede every deletion; add this refactor as a mutation fixture.

AGENTS.md reference: AGENTS.md:L49-L52

Useful? React with 👍 / 👎.

Co-Authored-By: brainlayerCodex-4c4386f6 <brainlayerCodex-4c4386f6@users.noreply.github.com>
@EtanHey

EtanHey commented Sep 9, 2026

Copy link
Copy Markdown
Owner Author

@codex review

Re-review latest head 2bcf4eff. The earlier findings now have RED mutations for early-success bypasses, positive verified and upload polarity, else-branch deletion, unreachable persistence, and durable-write ordering. Pair review also found and verified closure of a permanently-falsy md5 input: the guard now pins the persisted-state consumer, uploaded.get("md5Checksum") producer, and Drive field request. Please attack whether any deletion can still bypass verified survivor-copy evidence.

— brainlayerCodex-4c4386f6 (worker) · codex/gpt-5.6-sol

Comment thread src/brainlayer/backup_retention_invariant.py Outdated
Comment thread src/brainlayer/backup_retention_invariant.py Outdated
@EtanHey EtanHey added size:M Tight-loop PR size: 151-400 hand-written lines changed and removed size:S Tight-loop PR size: 51-150 hand-written lines changed labels Sep 9, 2026
@EtanHey EtanHey changed the title test(backup): make survivor-copy retention invariant structural test(backup): harden survivor-copy retention guard Sep 9, 2026
@EtanHey
EtanHey changed the base branch from wt/p0-retention to wt/laneb-retention-guard-core September 9, 2026 18:34

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2bcf4eff35

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +242 to +245
verified_gates = [
node for node in ast.walk(run_backup) if isinstance(node, ast.If) and _is_exact_verified_upload_gate(node)
]
verified_gate = verified_gates[0] if len(verified_gates) == 1 else None

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Pin the verified value to the bundle check

Fresh evidence beyond the earlier gate-polarity finding: inserting result["verified"] = True immediately after verify_jsonl_bundle(...) leaves this inspector clean because it validates only the shape of the later condition, not the producer of result["verified"]. With a corrupt bundle, that mutation permits state persistence, Drive pruning, and deletion of the only local archive despite failed verification; require the gate value to come from verify_jsonl_bundle and add this mutation.

AGENTS.md reference: AGENTS.md:L33-L36

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in da6ad9b0. The exact result.update(verify_jsonl_bundle(...)) statement must now immediately precede the verified-upload deletion gate, so an intervening result["verified"] = True mutation fails.

— brainlayerCodex-4c4386f6 (worker) · codex/gpt-5.6-sol

Comment on lines 67 to 74
for node in ast.walk(function):
if not isinstance(node, ast.Compare) or not any(isinstance(op, operator) for op in node.ops):
continue
if _inside_statically_dead_branch(node, parents):
continue
rendered = ast.unparse(node)
if all(term in rendered for term in terms):
return True

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Require survivor comparisons to control successful returns

Fresh evidence beyond the earlier early-success finding: changing the membership rejection to if archive_id not in surviving_archives and False: still produces no violations because the comparison is considered reachable merely by occurring in a non-constant if, while the final digest return remains allow-listed. For an archive ID absent from Drive, _state_matches can then return true and continue treating a deleted archive as the survivor copy; validate that each evidence predicate actually dominates success rather than only searching for its AST node.

AGENTS.md reference: AGENTS.md:L33-L36

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in da6ad9b0. Survivor identity and MD5 rejection are now pinned as exact executable conditions whose sole body is return False; the reproduced and False bypass is a RED mutation and fails.

— brainlayerCodex-4c4386f6 (worker) · codex/gpt-5.6-sol

Comment thread src/brainlayer/backup_retention_invariant.py
Co-Authored-By: brainlayerCodex-4c4386f6 <brainlayerCodex-4c4386f6@users.noreply.github.com>
Comment thread src/brainlayer/backup_retention_invariant.py
EtanHey and others added 2 commits September 9, 2026 21:41
Co-Authored-By: brainlayerCodex-4c4386f6 <brainlayerCodex-4c4386f6@users.noreply.github.com>
Co-Authored-By: brainlayerCodex-4c4386f6 <brainlayerCodex-4c4386f6@users.noreply.github.com>
@EtanHey
EtanHey changed the base branch from wt/laneb-retention-guard-core to main September 9, 2026 19:12
@EtanHey
EtanHey marked this pull request as ready for review September 9, 2026 20:03
@cursor

cursor Bot commented Sep 9, 2026

Copy link
Copy Markdown

Bugbot couldn't run - usage limit reached

Bugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit.

A user or team admin can review and increase usage limits in the Cursor dashboard.

(requestId: serverGenReqId_8043e994-f951-418c-bc25-d58c4e9ce2ed)

@cursor

cursor Bot commented Sep 9, 2026

Copy link
Copy Markdown

Bugbot couldn't run - usage limit reached

Bugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit.

A user or team admin can review and increase usage limits in the Cursor dashboard.

(requestId: serverGenReqId_6dab58b1-1dce-41d5-973f-0386c2d5ff50)

@github-actions

github-actions Bot commented Sep 9, 2026

Copy link
Copy Markdown

BrainLayer ratchet

Every Value below was measured by this run. A row this machine cannot measure says n/a — <reason> instead of a number; baselines in Notes name their own machine, method and date and were not measured here.

Row Status Value (measured by this run) Method Notes
commit provenance 🟢 GREEN measured 96e9afecf6a0 == PR head · checkout 57daca71982f commit graph + live PR head · in-process · runner Which commit this whole table is about. On a pull_request event the checkout is GitHub's synthetic merge ref, whose sha is not on the PR — #759's table printed 13fa724278bf while that PR's head was 4632f979 — so this row names the PR-head parent instead, the sha a reviewer can actually see. The comparison sha is read live from repos/{owner}/{repo}/pulls/{n} when the table is collected, not taken from the event payload, because the payload cannot know the run has been overtaken. Residual window, stated rather than papered over: a push landing between that read and the comment being posted is not caught here — the run for that push refreshes the table.
baseline attestation 🟢 GREEN baseline f421d1a7c5e6 matches the main attestation (run 34398682289 · main 5bb7b5bf16cb · 2026-09-09T20:03:12Z) main attestation artifact via Actions API · in-process · runner What every comparison is measured AGAINST, and who says so. The baseline fields of tests/fixtures/sprint_gate/corpus.json (queries, latency_baseline_ms, thresholds) are compared to the ratchet-attestation artifact of the latest successful push or (no-input) workflow_dispatch run of ratchet-attest.yml on main, fetched through the Actions API — a PR run cannot write to another run's artifacts. A field that differs is RED unless that main run measured the new value. The calibrated socket collector can license p50/p95; every absent measured path stays locked, so missing collection never passes as permission for a hand edit. Boundary: the comparator is this PR's checkout of ci_ratchet_table.py, diff-reviewable, not tamper-proof.
provenance 🟢 GREEN stamped 57daca71982f == HEAD, tree clean wheel stamp · in-process · runner Sha half of #749 keg-mode provenance: a keg built from this wheel can answer __build_sha__. The helper-age and served-process predicates need a running BrainBar and are measured only by scripts/sprint_gate.py on an installed Mac. The sha here is the checkout's — the merge ref on a PR — because that is what publish.yml stamps at release time; the PR-head sha this table describes is the one in commit provenance above.
fallback replay debt ⚪ n/a n/a — no fallback queue on this machine: the pending memories live in ~/Gits/*/docs.local/decisions, and docs.local/ is gitignored, so a runner checkout has no copy of them to count docs.local walk · machine with the fallback queue intended_brain_store: true with no chunk_id means a memory reached disk and never reached the DB, so it answers no brain_search. Budget: 0. Any pending or unparseable file is a finding, never a band -- 122 of these sat from 2026-06-28 to 2026-09-05 because nothing counted them where a reader would look. Measured by walking the tree, so it is only ever measured on a machine that HAS the tree.
mapped bytes ⚪ n/a n/a — no BrainBar daemon at /tmp/brainbar.sock: this row needs the daemon, its hybrid helper and the indexed corpus running together, and no GitHub-hosted runner has them (macOS included) — only a self-hosted Darwin/arm64 runner on an installed Mac would socket · installed Mac Baseline 26.2 GB — installed Mac, socket, 2026-09-03, after R2 drained 15,070 → 0. Up from 16.8 GB because the drain left more vectors mapped under the same cap: the change is the drain, not a leak. Not measured by this run.
search p50/p95 ⚪ n/a n/a — no BrainBar daemon at /tmp/brainbar.sock: this row needs the daemon, its hybrid helper and the indexed corpus running together, and no GitHub-hosted runner has them (macOS included) — only a self-hosted Darwin/arm64 runner on an installed Mac would socket · installed Mac Margin p50: margin unmeasured — 0 of the 5 attested green main runs it needs; no verdict is rendered from fewer. Margin p95: margin unmeasured — 0 of the 5 attested green main runs it needs; no verdict is rendered from fewer. Calibrated on MacBook-Pro.local at 2026-09-01T08:42:22Z under active_sprint_load (tests/fixtures/sprint_gate/corpus.json). Not measured by this run.
idle CPU ⚪ n/a n/a — no BrainBar daemon at /tmp/brainbar.sock: this row needs the daemon, its hybrid helper and the indexed corpus running together, and no GitHub-hosted runner has them (macOS included) — only a self-hosted Darwin/arm64 runner on an installed Mac would ps sampling · installed Mac Ceiling: average CPU < 30% over a 60 s window (resource_budget in scripts/sprint_gate.py), ratified and kept as a hard budget. Margin daemon: margin unmeasured — 0 of the 5 attested green main runs it needs; no verdict is rendered from fewer. Margin helper: margin unmeasured — 0 of the 5 attested green main runs it needs; no verdict is rendered from fewer. Margin watcher: margin unmeasured — 0 of the 5 attested green main runs it needs; no verdict is rendered from fewer. Needs the BrainBar daemon, helper and watcher actually running. Not measured by this run.
signature_valid ⚪ n/a n/a — the macOS signature-parity job is trigger-gated and did not run on this PR: it touches no release or signing path (pyproject.toml, scripts/release-*, scripts/brainlayer-version-check.sh, publish.yml, ratchet.yml) and carries no ratchet:signatures label — a GitHub macOS runner bills at ~10× Linux minutes and rebuilds the keg venv from source codesign · installed keg scripts/release-verify-signatures.sh <keg> codesign-verifies every *.so/*.dylib under libexec/venv. The macOS parity job installs the published tap formula (etanhey/layers/brainlayer), so this row measures the release path — formula, published sdist and Homebrew's relocation — and not this PR's tree. Release-time baseline for the same keg on a different machine: 442 valid / 0 invalid — installed Mac (M4 Max), brew --prefix brainlayer 1.5.11, 2026-09-03.

🟢 GREEN measured, within budget · 🔴 RED measured, out of budget — a finding to clear before merge · ⚪ n/a not measurable on this machine, never guessed.

No RED rows.

Measured on Linux/x86_64 · measured 96e9afecf6a0 · PR head 96e9afecf6a0 · checkout 57daca71982f · run · updated 2026-09-09 20:13:40 UTC

Co-Authored-By: brainlayerCodex-4c4386f6 <brainlayerCodex-4c4386f6@users.noreply.github.com>
@cursor

cursor Bot commented Sep 9, 2026

Copy link
Copy Markdown

Bugbot couldn't run - usage limit reached

Bugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit.

A user or team admin can review and increase usage limits in the Cursor dashboard.

(requestId: serverGenReqId_4fe518de-20a9-44b8-98da-961c3b79c2a4)

parents: dict[ast.AST, ast.AST],
) -> bool:
expected = ast.parse(condition, mode="eval").body
for node in ast.walk(function):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium brainlayer/backup_retention_invariant.py:86

_has_exact_false_rejection can return True for a guard inside an uncalled nested helper, so the invariant checker accepts _state_matches even when its live archive-ID or md5 rejection has been removed. This happens because ast.walk(function) descends into nested FunctionDef bodies; restrict the traversal to the current function and exclude nested function scopes.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/brainlayer/backup_retention_invariant.py around line 86:

`_has_exact_false_rejection` can return `True` for a guard inside an uncalled nested helper, so the invariant checker accepts `_state_matches` even when its live archive-ID or md5 rejection has been removed. This happens because `ast.walk(function)` descends into nested `FunctionDef` bodies; restrict the traversal to the current function and exclude nested function scopes.

@EtanHey
EtanHey merged commit 78c1d74 into main Sep 9, 2026
25 of 28 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:M Tight-loop PR size: 151-400 hand-written lines changed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant