commit 37e852e6e348fcf4245fa47b4d46879de6dbb63c
parent bbc3e818b865c27b6f5c3f03e809d36b2d07597f
Author: James Eversole <james@eversole.co>
Date: Wed, 14 Oct 2026 10:53:21 -0500
Make rolling retention part of compiler checkpoint completion
Track explicit plan/apply/check tooling with protected toolchains, registered Nix runtime roots, inode-aware reclaim estimates, compact CAS evidence, isolated pre/post checks, journaled deletion and a failing incomplete-checkpoint gate. Coordinate generation readers/writers with scratch-root locks and reject pruned generation inputs/output resumption.
Validation: nix flake check passed, including the new 20 filesystem/retention controls and the Haskell test suite.
Diffstat:
8 files changed, 880 insertions(+), 3 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md
@@ -47,6 +47,20 @@ nix flake check
nix build .#
```
+### Compiler checkpoint completion includes cleanup
+
+A verified generation-two compiler is not a finished development checkpoint until
+rolling retention is complete. After pair acceptance and smokes, run the tracked
+`test/bootstrap_retention.py` plan/apply/check workflow in
+[compiler rebuilding](docs/compiler-rebuilding.md.html#rolling-development-retention).
+Keep explicitly selected current and rollback toolchains, their frozen inputs,
+current support preparation, and registered Nix runtime roots. Prune superseded
+scratch objects in the same checkpoint; do not defer this to a future agent.
+The completion check must pass before declaring the checkpoint finished or
+starting the next feature batch. If cleanup is blocked, report the blocker and
+outstanding bytes as an incomplete checkpoint. Recovery archives are never routine
+scratch-cleanup targets. Do not implement retention tooling in ignored scratch.
+
### Host scope: Haskell only
Use the Haskell host for implementation, debugging, profiling, benchmarking,
diff --git a/docs/compiler-rebuilding.md b/docs/compiler-rebuilding.md
@@ -154,9 +154,10 @@ and these smokes complement the batch's independent semantic tests.
### Verified compiler checkpoint; publication deferred
An explicitly agreed checkpoint may promote the audited fresh pair after the
-smokes while deferring fixture publication, the broad downstream sweep, and
-recovery packaging. Record **verified compiler checkpoint, publication deferred**,
-with source commit, tool identities, coverage, and remaining obligations. Retain
+smokes and [rolling retention](#rolling-development-retention) while deferring
+fixture publication, the broad downstream sweep, and recovery packaging. Record
+**verified compiler checkpoint, publication deferred**, with source commit, tool
+identities, coverage, and remaining obligations. Retain
the previous accepted compiler for rollback. This promotes a development compiler,
not the entire support cohort or its fixture publication.
@@ -279,6 +280,125 @@ its original full-pair audit cannot be rerun in place; preserve that distinction
in the retained evidence. Off-site recovery packages remain outside routine
scratch cleanup.
+### Checkpoint cleanup command
+
+Pair acceptance and smokes do not finish the operational checkpoint. Run
+`test/bootstrap_retention.py` after them, before declaring completion or starting
+the next feature batch. This is an opt-in Linux/Haskell workflow requiring Python,
+Nix and `bwrap`; it never builds another full compiler generation or runs Nix GC.
+An existing verified checkpoint can satisfy its outstanding retention obligation
+without building a replacement pair.
+
+Create an explicit local retention registry, for example
+`$SCRATCH/retained-toolchains.json`. Replace the example paths and runtime hashes
+with recorded provenance; do not select compilers by directory age. `current` and
+`rollback` are required unique roles. Additional uniquely named toolchains or
+protected scratch directories are allowed for active consumers and release evidence.
+Prefer an independently useful prior compiler revision for rollback: two builds
+with identical executable bundles do not provide an executable revision rollback.
+
+```json
+{
+ "schema": "tricu.bootstrap-retention.v1",
+ "protected": ["current-cohort", "previous-cohort"],
+ "sibling_users_checked": true,
+ "uncoordinated_jobs_stopped": true,
+ "toolchains": [
+ {
+ "role": "current",
+ "generation": "current-cohort/generation2",
+ "source": "current-cohort/source",
+ "acceptance": "current-cohort/accepted-pair.json",
+ "runtime": "/nix/store/CURRENT-RUNTIME/bin/tricu"
+ },
+ {
+ "role": "rollback",
+ "generation": "previous-cohort/generation2",
+ "source": "previous-cohort/source",
+ "acceptance": "previous-cohort/accepted-pair.json",
+ "runtime": "/nix/store/PREVIOUS-RUNTIME/bin/tricu"
+ }
+ ],
+ "support": {
+ "toolchain": "current",
+ "preparation": "current-cohort/support",
+ "relocate_patterns": true
+ }
+}
+```
+
+All scratch paths are normalized, root-relative, and protected by explicit real
+**directories**. The current command reruns full-pair acceptance, so both retained
+first generations must also be protected and present. It does not automate their
+retirement. Acceptance must select the actual retained second-generation path;
+for a relocated pair, create relocated evidence with `bootstrap_acceptance.py`
+and its explicit source override rather than rewriting old acceptance records.
+The selected standard adapters remain inside the protected generations.
+
+The support preparation must contain the complete selected candidate cohort in
+one directory. The command reproduces current support bundles, metadata, receipts
+and full native manifests through the existing publisher, then checks source
+freshness. It does not publish or edit fixtures. `relocate_patterns` selects the
+current V3 publication procedure. Protect any other preparation or store used by
+active work before planning. The two confirmation fields are operator obligations,
+not automatic discovery of every external user.
+
+```sh
+SCRATCH="$PWD/.bootstrap-run"
+CHECKPOINT="$SCRATCH/retention-checkpoint-UNIQUE"
+python3 test/bootstrap_retention.py plan --root "$SCRATCH" \
+ --config "$SCRATCH/retained-toolchains.json" --directory "$CHECKPOINT"
+
+# Review plan.json: protected inputs, exact objects/ paths, compact evidence,
+# and allocated-byte reclaim estimate. Use the SHA256 printed by plan.
+python3 test/bootstrap_retention.py apply --root "$SCRATCH" \
+ --directory "$CHECKPOINT" --approve-plan REVIEWED_PLAN_SHA256
+
+# Mandatory completion gate: nonzero while pending or evidence/inputs differ.
+python3 test/bootstrap_retention.py check --root "$SCRATCH" \
+ --directory "$CHECKPOINT"
+```
+
+Planning registers indirect Nix GC roots for each exact accepted Haskell runtime.
+It inventories allocated blocks by inode, excludes hardlinks still referenced by
+retained or external files from reclaim estimates, and preserves compact
+header-bearing CAS documents with whole-file hashes before any deletion.
+Only selected `objects/` directories inside this `.bootstrap-run` are candidates.
+Sources, portable bundles, original reports and archives outside that scope are
+not deleted. Each removed CAS gets a `PRUNED-CAS.json` marker; old completion
+records become historical evidence, not a resumable or complete store.
+
+Application holds an exclusive scratch-root lock. Generation runners take shared
+locks on previous/output scratch roots, so they refuse concurrent cleanup.
+Existing generation output locks are checked too. Stop other tools and external
+users separately: arbitrary host invocations do not participate in this lock.
+The command assumes trusted local filesystems, not adversarial writers or mounts.
+
+Before deletion, `bwrap` hides the entire scratch root, exposing only registered
+protected directories and the writable checkpoint evidence directory. Each
+selected compiler passes independent pair audits, CLI compilation/execution,
+diagnostics and integrity controls, correctness smokes, and a fresh scoped
+`native_math` map-builder/adapter job with complete-manifest comparison. Support
+reproduction must pass in that isolation too. The same checks run after deletion;
+only then can the checkpoint's `plan.json` become `complete: true` and the completion
+gate succeed. Verification logs, reports, registry and harness snapshots remain
+beside the plan. Keep those evidence directories and their runtime roots.
+
+Deletion is journaled and resumable. A failed precheck deletes nothing. A failed
+postcheck leaves retention incomplete even if unlinking finished. For resumption,
+review the updated plan and approve its **current** SHA256; changed protected inputs,
+candidates, compact evidence or deletion harness block resumption. The completion
+checker can read archived harness identities after tooling evolves, but an in-flight
+delete must use its reviewed harness. Record blockers and outstanding bytes rather
+than calling an incomplete checkpoint finished. Never wait until disk pressure to
+perform the next rotation.
+
+Safety controls run in the fast Nix check without compiling tricu programs:
+
+```sh
+nix build .#checks.x86_64-linux.bootstrapRetention --no-link
+```
+
## Recovery archives
Select a completed generation, its matching frozen commit, standard adapter,
diff --git a/flake.nix b/flake.nix
@@ -325,6 +325,14 @@
--seeder ${./ext/demo-site/seed.py}
mkdir -p $out
'';
+ checks.bootstrapRetention = pkgs.runCommand "tricu-bootstrap-retention-tests" {
+ nativeBuildInputs = [ pkgs.python3 ];
+ } ''
+ export PYTHONDONTWRITEBYTECODE=1
+ cd ${tricuSource}
+ python3 test/bootstrap_retention_test.py
+ mkdir -p $out
+ '';
checks.hostPolicy = pkgs.stdenv.mkDerivation {
pname = "tricu-host-policy-tests";
version = "0.1.0";
diff --git a/test/bootstrap_generations.py b/test/bootstrap_generations.py
@@ -105,6 +105,13 @@ def main():
parser.error('invalid resource boundaries')
previous = args.previous.resolve() if args.previous else None
output = args.output.resolve()
+ from bootstrap_retention_lock import protect_generations
+ retention_locks = protect_generations([previous, output])
+ for selected in (previous, output):
+ if selected and any((selected / store / 'PRUNED-CAS.json').exists()
+ for store in ('cas', 'compiler-cas')):
+ parser.error('pruned generations are historical evidence, not resumable stores; '
+ 'select a retained input and a fresh output directory')
if args.bootstrap_kit:
if previous or args.adapter or args.worker_bundle or args.expect_identical:
parser.error('--bootstrap-kit replaces previous/adapter/worker selection; reproduction uses the first generation')
diff --git a/test/bootstrap_retention.py b/test/bootstrap_retention.py
@@ -0,0 +1,426 @@
+#!/usr/bin/env python3
+"""Finish a verified compiler checkpoint: plan, isolated checks, prune, recheck.
+
+Linux/Haskell only. Trusted local scratch trees, not adversarial filesystems.
+Only objects/ directories under an explicit .bootstrap-run root are removed.
+No archive discovery/deletion, no age-based compiler selection, no Nix GC.
+The config is an explicit retention registry; see compiler-rebuilding.md.
+"""
+import argparse
+import collections
+from contextlib import ExitStack
+import fcntl
+import hashlib
+import json
+import os
+from pathlib import Path
+import re
+import shutil
+import stat
+import subprocess
+import sys
+import time
+
+from bootstrap_retention_lock import acquire
+
+REPO = Path(__file__).resolve().parent.parent
+SCHEMA = 'tricu.bootstrap-retention.v1'
+
+
+def sha(path):
+ h = hashlib.sha256()
+ with Path(path).open('rb') as f:
+ for block in iter(lambda: f.read(1024 * 1024), b''):
+ h.update(block)
+ return h.hexdigest()
+
+
+def save(path, value):
+ temporary = path.with_name(path.name + '.tmp')
+ with temporary.open('w') as f:
+ json.dump(value, f, indent=2); f.write('\n'); f.flush(); os.fsync(f.fileno())
+ os.replace(temporary, path)
+ fd = os.open(path.parent, os.O_DIRECTORY)
+ try:
+ os.fsync(fd)
+ finally:
+ os.close(fd)
+
+
+def within(root, relative, exists=True):
+ p = Path(relative)
+ if p.is_absolute() or not p.parts or any(x in ('.', '..') for x in p.parts):
+ raise ValueError('expected normalized scratch-relative path: ' + str(relative))
+ result = root / p
+ if str(p) != relative or result.resolve() != result:
+ raise ValueError('symlink/non-normalized path: ' + relative)
+ if exists and not result.exists():
+ raise ValueError('missing path: ' + relative)
+ return result
+
+
+def files(path):
+ for parent, dirs, names in os.walk(path, followlinks=False):
+ for name in dirs + names:
+ p = Path(parent) / name
+ s = p.lstat()
+ if stat.S_ISLNK(s.st_mode):
+ # Retained cohorts can contain Nix runtime roots; record links,
+ # never traverse or modify their targets.
+ if name in dirs:
+ dirs.remove(name)
+ yield p
+ elif not (stat.S_ISREG(s.st_mode) or stat.S_ISDIR(s.st_mode)):
+ raise ValueError('special file: ' + str(p))
+ for name in names:
+ yield Path(parent) / name
+ for name in dirs[:]:
+ p = Path(parent) / name
+ if p.is_symlink():
+ dirs.remove(name)
+ yield p
+
+
+def identity(p):
+ s = p.lstat()
+ return [s.st_dev, s.st_ino, s.st_mode, s.st_size, s.st_mtime_ns,
+ s.st_nlink, s.st_blocks * 512, os.readlink(p) if p.is_symlink() else None]
+
+
+def inventory(path):
+ return {str(p.relative_to(path)): identity(p) for p in sorted(files(path))}
+
+
+def fingerprint(root, protected):
+ payload = {p: inventory(root / p) for p in protected}
+ # Unlinking old hardlinks must not count as modifying a retained object.
+ for records in payload.values():
+ for value in records.values():
+ value[5] = None
+ return hashlib.sha256(json.dumps(payload, sort_keys=True).encode()).hexdigest()
+
+
+def load_config(root, path):
+ config = json.loads(path.read_text())
+ if config.get('schema') != SCHEMA:
+ raise ValueError('invalid retention registry schema')
+ protected = config['protected']
+ if not protected or len(set(protected)) != len(protected):
+ raise ValueError('explicit unique protected paths required')
+ for p in protected:
+ if not within(root, p).is_dir():
+ raise ValueError('protected path is not a directory')
+ def covered(relative):
+ within(root, relative)
+ if not any(relative == p or relative.startswith(p + '/') for p in protected):
+ raise ValueError('registry input is not protected: ' + relative)
+ roles = [t['role'] for t in config['toolchains']]
+ if roles.count('current') != 1 or roles.count('rollback') != 1 or len(set(roles)) != len(roles):
+ raise ValueError('one current and one rollback toolchain required; roles must be unique')
+ for t in config['toolchains']:
+ if not re.fullmatch(r'[a-z][a-z0-9-]*', t['role']):
+ raise ValueError('unsafe toolchain role')
+ for key in ('generation', 'source', 'acceptance'):
+ covered(t[key])
+ accepted = json.loads((root / t['acceptance']).read_text())
+ if not accepted.get('accepted') or accepted.get('backend') != 'stable':
+ raise ValueError('accepted stable pair required')
+ if Path(accepted['generations'][1]).resolve() != root / t['generation']:
+ raise ValueError('acceptance must select this second generation; relocate acceptance first')
+ for generation in accepted['generations']:
+ path = Path(generation).resolve()
+ if not path.is_relative_to(root):
+ raise ValueError('retained acceptance pair must be relocated inside scratch root')
+ covered(str(path.relative_to(root)))
+ runtime = Path(t['runtime']).resolve()
+ if not str(runtime).startswith('/nix/store/') or runtime.name != 'tricu':
+ raise ValueError('explicit Nix-built Haskell tricu runtime required')
+ if sha(runtime) != accepted['inputs'][1]['runtime_sha256']:
+ raise ValueError('accepted runtime identity mismatch')
+ for name, digest in accepted['artifacts'].items():
+ if sha(root / t['generation'] / name) != digest:
+ raise ValueError('accepted artifact mismatch')
+ covered(config['support']['preparation'])
+ if config['support']['toolchain'] not in roles:
+ raise ValueError('unknown support toolchain')
+ if not config.get('sibling_users_checked') or not config.get('uncoordinated_jobs_stopped'):
+ raise ValueError('confirm sibling users and stop uncoordinated jobs before planning')
+ return config
+
+
+def object_directories(root, protected):
+ found = []
+ for parent, dirs, _ in os.walk(root, followlinks=False):
+ parent = Path(parent)
+ relative = str(parent.relative_to(root))
+ if any(relative == p or relative.startswith(p + '/') for p in protected):
+ dirs[:] = []; continue
+ for name in dirs[:]:
+ p = parent / name
+ if p.is_symlink():
+ if name == 'objects':
+ raise ValueError('symlink objects directory: ' + str(p))
+ dirs.remove(name)
+ elif name == 'objects':
+ found.append(str(p.relative_to(root))); dirs.remove(name)
+ return sorted(found)
+
+
+def make_plan(root, config, out):
+ protected = config['protected']
+ harness = out / 'harness'; harness.mkdir()
+ for name in ['bootstrap_retention.py', 'bootstrap_retention_probe.py', 'bootstrap_retention_lock.py']:
+ shutil.copyfile(REPO / 'test' / name, harness / name)
+ candidates = object_directories(root, protected + [str(out.relative_to(root))])
+ entries = []
+ metadata = out / 'historical-metadata'; metadata.mkdir()
+ deleting = collections.Counter(); allocated = {}; all_links = collections.Counter()
+ for p in files(root):
+ s = p.lstat()
+ if stat.S_ISREG(s.st_mode):
+ all_links[s.st_dev, s.st_ino] += 1
+ allocated[s.st_dev, s.st_ino] = (s.st_blocks * 512, s.st_nlink)
+ for relative in candidates:
+ target = within(root, relative)
+ records = inventory(target)
+ evidence = []
+ for name, s in records.items():
+ p = target / name
+ if not stat.S_ISREG(s[2]):
+ raise ValueError('symlink/non-file inside objects: ' + str(p))
+ deleting[s[0], s[1]] += 1
+ with p.open('rb') as f:
+ head = f.read(128)
+ domain = head.split(b'\n', 1)[0]
+ if re.fullmatch(rb'arboricx\.[a-zA-Z0-9_.-]+', domain) and b'\n' in head:
+ digest = sha(p); saved = metadata / digest
+ if not saved.exists():
+ shutil.copyfile(p, saved)
+ evidence.append({'path': name, 'sha256': digest, 'domain': domain.decode()})
+ entries.append(dict(objects=relative, files=records, metadata=evidence, state='pending'))
+ reclaim = sum(allocated[k][0] for k, count in deleting.items()
+ if count == all_links[k] == allocated[k][1])
+ return dict(schema=SCHEMA, root=str(root), config=config, complete=False,
+ phase='planned', planned_at=time.time(), stores=entries,
+ reclaim_bytes=reclaim, free_bytes_before=shutil.disk_usage(root).free,
+ protected_fingerprint=fingerprint(root, protected),
+ tool_sha256=sha(Path(__file__)), probe_sha256=sha(REPO / 'test/bootstrap_retention_probe.py'))
+
+
+def validate_plan(root, plan, out, same_tools=True):
+ if plan['schema'] != SCHEMA or plan['root'] != str(root):
+ raise ValueError('plan/root mismatch')
+ if same_tools:
+ if plan['tool_sha256'] != sha(Path(__file__)) or plan['probe_sha256'] != sha(REPO / 'test/bootstrap_retention_probe.py'):
+ raise ValueError('retention tooling changed; do not reuse this plan')
+ else:
+ # Completed historical evidence can be checked by newer tooling, but
+ # deletion/resumption must always use the original reviewed harness.
+ for name, key in [('bootstrap_retention.py', 'tool_sha256'),
+ ('bootstrap_retention_probe.py', 'probe_sha256')]:
+ if sha(out / 'harness' / name) != plan[key]:
+ raise ValueError('archived retention harness changed')
+ protected = plan['config']['protected']
+ if fingerprint(root, protected) != plan['protected_fingerprint']:
+ raise ValueError('protected inputs changed since planning')
+ seen = set()
+ for row in plan['stores']:
+ relative = row['objects']
+ target = within(root, relative, exists=False)
+ if target.name != 'objects' or relative in seen:
+ raise ValueError('invalid/duplicate deletion target')
+ seen.add(relative)
+ if any(target == root / p or target.is_relative_to(root / p) or (root / p).is_relative_to(target)
+ for p in protected + [str(out.relative_to(root))]):
+ raise ValueError('deletion intersects protected inputs')
+ actual = inventory(target) if target.exists() else {}
+ def unchanged(name, value):
+ old = row['files'].get(name)
+ if old is None:
+ return False
+ # Deletion of another planned hardlink may lower this count.
+ return value[:5] == old[:5] and 0 < value[5] <= old[5] and value[6:] == old[6:]
+ if row['state'] == 'pending' and (not target.is_dir() or set(actual) != set(row['files'])
+ or not all(unchanged(k, v) for k, v in actual.items())):
+ raise ValueError('candidate changed since planning: ' + relative)
+ if row['state'] == 'deleting' and not all(unchanged(k, v) for k, v in actual.items()):
+ raise ValueError('interrupted candidate changed: ' + relative)
+ if row['state'] == 'removed' and target.exists():
+ raise ValueError('removed candidate reappeared: ' + relative)
+ if row['state'] not in ('pending', 'deleting', 'removed'):
+ raise ValueError('unknown deletion state')
+ for e in row['metadata']:
+ if not re.fullmatch('[a-f0-9]{64}', e['sha256']) or sha(out / 'historical-metadata' / e['sha256']) != e['sha256']:
+ raise ValueError('preserved metadata changed')
+
+
+def isolated_checks(root, plan, out, phase):
+ destination = out / (phase + '-' + str(time.time_ns()))
+ destination.mkdir()
+ command = ['bwrap', '--die-with-parent', '--unshare-pid', '--ro-bind', '/', '/',
+ '--proc', '/proc', '--tmpfs', '/tmp', '--tmpfs', str(root)]
+ for relative in plan['config']['protected']:
+ command += ['--ro-bind', str(root / relative), str(root / relative)]
+ command += ['--bind', str(out), str(out), '--setenv', 'PYTHONDONTWRITEBYTECODE', '1',
+ '--', sys.executable, str(REPO / 'test/bootstrap_retention_probe.py'),
+ '--root', str(root), '--plan', str(out / 'plan.json'), '--directory', str(destination)]
+ with (destination / 'checks.log').open('w') as log:
+ subprocess.run(command, stdout=log, stderr=subprocess.STDOUT, check=True, timeout=3600)
+ report = json.loads((destination / 'report.json').read_text())
+ if not report.get('complete') or not report.get('obsolete_stores_hidden'):
+ raise ValueError('incomplete isolated checks')
+ return str(destination.relative_to(out))
+
+
+def prune(root, plan, out):
+ # Validate ALL inputs before the first unlink. Revalidate on every resumption.
+ validate_plan(root, plan, out)
+ for row in plan['stores']:
+ if row['state'] == 'removed':
+ continue
+ target = root / row['objects']
+ save(target.parent / 'PRUNED-CAS.json', dict(objects_removed=row['objects'],
+ retention_report=str(out / 'plan.json'), complete=False, resumable=False,
+ note='Historical records only; full CAS/pair audit is no longer available here.'))
+ row['state'] = 'deleting'; plan['phase'] = 'deleting'; save(out / 'plan.json', plan)
+ if target.exists():
+ shutil.rmtree(target)
+ row['state'] = 'removed'; save(out / 'plan.json', plan)
+ validate_plan(root, plan, out)
+ plan['phase'] = 'deleted'; save(out / 'plan.json', plan)
+
+
+def validate_check_reports(root, plan, out, phases):
+ for phase in phases:
+ report_path = within(out, plan[phase]) / 'report.json'
+ report = json.loads(report_path.read_text())
+ if phase + '_sha256' in plan and sha(report_path) != plan[phase + '_sha256']:
+ raise ValueError('checkpoint check report changed')
+ if not all(report.get(k) for k in ['complete', 'obsolete_stores_hidden',
+ 'support_receipts_reproduced', 'fixture_sources_current']):
+ raise ValueError('checkpoint verification incomplete')
+ expected_roles = {t['role'] for t in plan['config']['toolchains']}
+ if {t['role'] for t in report['toolchains']} != expected_roles or len(report['toolchains']) != len(expected_roles):
+ raise ValueError('checkpoint toolchain coverage mismatch')
+ for t in plan['config']['toolchains']:
+ checked = next(c for c in report['toolchains'] if c['role'] == t['role'])
+ accepted = json.loads((root / t['acceptance']).read_text())
+ manifest = (root / t['generation'] / 'cas/cache/source-build/native_math').read_text().split('\t')[2]
+ if (checked['artifacts'] != accepted['artifacts'] or
+ checked['acceptance_sha256'] != sha(root / t['acceptance']) or
+ checked['runtime_sha256'] != sha(t['runtime']) or
+ checked['complete_results'] != 12 or not checked['portable_cli'] or
+ checked['scoped_map_manifest'] != manifest):
+ raise ValueError('checkpoint toolchain evidence mismatch')
+ if set(report['support_sha256']) != {'support-library.arboricx', 'support-rebuilt.arboricx', 'support-library.v1'}:
+ raise ValueError('checkpoint support coverage mismatch')
+ for name, digest in report['support_sha256'].items():
+ if sha(REPO / 'test/fixtures' / name) != digest:
+ raise ValueError('checkpoint support publication changed')
+
+
+def validate_completion(root, plan, out):
+ if not plan['complete'] or plan['phase'] != 'complete' or any(r['state'] != 'removed' for r in plan['stores']):
+ raise ValueError('checkpoint retention is pending, not complete')
+ validate_check_reports(root, plan, out, ['prechecks', 'postchecks'])
+
+
+def checkpoint_main(locks):
+ p = argparse.ArgumentParser(description=__doc__)
+ p.add_argument('action', choices=['plan', 'apply', 'check'])
+ p.add_argument('--root', type=Path, required=True)
+ p.add_argument('--directory', type=Path, required=True, help='checkpoint evidence directory under root')
+ p.add_argument('--config', type=Path, help='explicit registry JSON, required for plan')
+ p.add_argument('--approve-plan', help='reviewed plan SHA256, required for apply')
+ args = p.parse_args()
+ root = args.root.absolute()
+ if root.name != '.bootstrap-run' or root.resolve() != root or not root.is_dir():
+ p.error('explicit real .bootstrap-run directory required')
+ out = args.directory.absolute()
+ if out.resolve() != out or not out.is_relative_to(root) or out == root:
+ p.error('evidence directory must be real and inside root')
+ locks.enter_context(acquire(root, exclusive=True))
+ # Also refuse pre-existing generation writers that predate root-lock support.
+ generation_locks = []
+ for parent, dirs, names in os.walk(root, followlinks=False):
+ if '.generation.lock' in names:
+ path = Path(parent) / '.generation.lock'
+ if path.is_symlink():
+ raise ValueError('symlink generation lock')
+ handle = locks.enter_context(path.open('r'))
+ fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
+ generation_locks.append(handle)
+ if args.action == 'plan':
+ if not args.config:
+ p.error('--config required')
+ config = load_config(root, args.config.resolve())
+ if any(out.is_relative_to(root / k) or (root / k).is_relative_to(out) for k in config['protected']):
+ raise ValueError('evidence directory intersects protected inputs')
+ out.mkdir(parents=True, exist_ok=False)
+ runtime_roots = {}
+ for t in config['toolchains']:
+ runtime_closure = Path(t['runtime']).resolve().parent.parent
+ runtime_root = out / ('runtime-' + t['role'])
+ subprocess.run(['nix-store', '--realise', str(runtime_closure),
+ '--add-root', str(runtime_root), '--indirect'],
+ check=True, capture_output=True, text=True, timeout=120)
+ runtime_roots[t['role']] = str(runtime_root)
+ plan = make_plan(root, config, out)
+ plan['runtime_roots'] = runtime_roots
+ save(out / 'plan.json', plan)
+ print(f'PLAN: {len(plan["stores"])} object directories; {plan["reclaim_bytes"] / 2**30:.2f} GiB reclaim estimate')
+ print('Review plan.json; apply requires --approve-plan ' + sha(out / 'plan.json'))
+ return
+ plan_path = out / 'plan.json'
+ if args.action == 'apply':
+ if args.approve_plan != sha(plan_path):
+ p.error('--approve-plan must equal the current reviewed plan SHA256 (also on resumption)')
+ plan = json.loads(plan_path.read_text())
+ load_config(root, plan_path_for_config(out, plan['config']))
+ validate_plan(root, plan, out, same_tools=args.action != 'check')
+ if args.action == 'check':
+ validate_completion(root, plan, out)
+ print('PASS: compiler checkpoint retention complete'); return
+ if plan['complete']:
+ print('Checkpoint retention already complete'); return
+ # A previously successful precheck remains valid only under unchanged inputs.
+ if 'prechecks' not in plan:
+ print('Running isolated pre-deletion checks (see checkpoint logs)...', flush=True)
+ plan['prechecks'] = isolated_checks(root, plan, out, 'before')
+ plan['prechecks_sha256'] = sha(out / plan['prechecks'] / 'report.json')
+ validate_plan(root, plan, out)
+ plan['phase'] = 'verified'; save(plan_path, plan)
+ validate_check_reports(root, plan, out, ['prechecks'])
+ print('Prechecks passed; pruning reviewed object directories...', flush=True)
+ prune(root, plan, out)
+ print('Running isolated post-deletion checks...', flush=True)
+ plan['postchecks'] = isolated_checks(root, plan, out, 'after')
+ plan['postchecks_sha256'] = sha(out / plan['postchecks'] / 'report.json')
+ validate_plan(root, plan, out)
+ plan.update(complete=True, phase='complete', completed_at=time.time(),
+ free_bytes_after=shutil.disk_usage(root).free)
+ plan['observed_free_bytes_gain'] = plan['free_bytes_after'] - plan['free_bytes_before']
+ validate_completion(root, plan, out)
+ save(plan_path, plan)
+ print(f'COMPLETE: estimated unlink reclaim {plan["reclaim_bytes"] / 2**30:.2f} GiB; '
+ f'observed filesystem free-space gain {plan["observed_free_bytes_gain"] / 2**30:.2f} GiB')
+
+
+def plan_path_for_config(out, config):
+ # Snapshot for the validator/probe; never overwrite the user's registry.
+ path = out / 'registry.json'
+ if path.exists():
+ if json.loads(path.read_text()) != config:
+ raise ValueError('registry snapshot changed')
+ else:
+ save(path, config)
+ return path
+
+
+def main():
+ with ExitStack() as locks:
+ checkpoint_main(locks)
+
+
+if __name__ == '__main__':
+ main()
diff --git a/test/bootstrap_retention_lock.py b/test/bootstrap_retention_lock.py
@@ -0,0 +1,31 @@
+"""Cooperative scratch-root exclusion for generations and checkpoint cleanup.
+External users must still pin their stores and stop uncoordinated jobs.
+"""
+import fcntl
+from pathlib import Path
+
+
+def scratch_root(path):
+ path = Path(path).absolute()
+ return next((p for p in (path, *path.parents) if p.name == '.bootstrap-run'), None)
+
+
+def acquire(root, exclusive=False):
+ root = Path(root)
+ root.mkdir(parents=True, exist_ok=True)
+ path = root / '.retention.lock'
+ if path.is_symlink():
+ raise ValueError('symlink retention lock')
+ handle = path.open('a')
+ try:
+ fcntl.flock(handle, (fcntl.LOCK_EX if exclusive else fcntl.LOCK_SH) | fcntl.LOCK_NB)
+ except BlockingIOError:
+ handle.close()
+ raise ValueError('scratch root is busy (generation or retention cleanup)')
+ return handle
+
+
+def protect_generations(paths):
+ # Keep the returned handles alive until the generation exits.
+ roots = sorted({r for p in paths if p is not None for r in [scratch_root(p)] if r})
+ return [acquire(r) for r in roots]
diff --git a/test/bootstrap_retention_probe.py b/test/bootstrap_retention_probe.py
@@ -0,0 +1,93 @@
+#!/usr/bin/env python3
+"""Isolated retained-toolchain probes, invoked only by bootstrap_retention.py.
+No compiler generation checkpoint or fixture publication is created here.
+"""
+import argparse
+import hashlib
+import json
+from pathlib import Path
+import subprocess
+import sys
+
+from bootstrap_retention import REPO, sha, save
+from bootstrap_acceptance import verify
+
+
+def main():
+ p = argparse.ArgumentParser(description=__doc__)
+ p.add_argument('--root', type=Path, required=True)
+ p.add_argument('--plan', type=Path, required=True)
+ p.add_argument('--directory', type=Path, required=True)
+ a = p.parse_args()
+ plan = json.loads(a.plan.read_text()); config = plan['config']
+ root, out = a.root, a.directory
+ # Refuse a non-isolated invocation; every removal target must be invisible.
+ if any((root / row['objects']).exists() for row in plan['stores']):
+ raise ValueError('obsolete object stores are still visible')
+ report = dict(complete=False, obsolete_stores_hidden=True, toolchains=[])
+ def run(label, command, seconds=900):
+ with (out / (label + '.log')).open('w') as log:
+ subprocess.run(command, stdout=log, stderr=subprocess.STDOUT, check=True, timeout=seconds)
+ try:
+ for t in config['toolchains']:
+ role = t['role']; generation = root / t['generation']; source = root / t['source']
+ acceptance = root / t['acceptance']; runtime = t['runtime']
+ accepted = json.loads(acceptance.read_text())
+ # These cohorts still include first generation, so rerun full-pair
+ # acceptance as well as the generation-two audit. Portable-only
+ # retirement of first generation needs an explicitly different gate.
+ fresh = verify(Path(accepted['generations'][0]), generation, Path(runtime), source)
+ if fresh['artifacts'] != accepted['artifacts']:
+ raise ValueError('retained acceptance artifacts differ')
+ run(role + '-portable', [sys.executable, str(REPO / 'test/portable_cli.py'), runtime,
+ '--backend', 'stable', '--indexed', '--compiler-bundle', str(generation / 'build-stable.arboricx')])
+ run(role + '-smoke', [sys.executable, str(REPO / 'test/bootstrap_smoke.py'), runtime,
+ '--generation', str(generation), '--acceptance', str(acceptance),
+ '--source-root', str(source), '--directory', str(out / (role + '-smoke'))])
+ scoped = out / (role + '-map')
+ run(role + '-map', [sys.executable, str(REPO / 'test/bootstrap_generations.py'),
+ str(generation), str(scoped), '--tricu', runtime, '--source-root', str(source),
+ '--map', str(generation / 'source-map.v1'), '--root', 'native_math', '--backend', 'stable',
+ '--fresh', '--adapter', str(generation / 'io-adapter.arboricx'), '--capabilities', '2',
+ '--memory-mib', '8192', '--wall-seconds', '900'])
+ run(role + '-map-audit', [sys.executable, str(REPO / 'test/bootstrap_store_check.py'),
+ str(scoped), '--source-root', str(source), '--root', 'native_math'])
+ old = (generation / 'cas/cache/source-build/native_math').read_text().split('\t')[2]
+ new = (scoped / 'cas/cache/source-build/native_math').read_text().split('\t')[2]
+ if old != new:
+ raise ValueError('scoped map-builder changed complete native_math manifest')
+ smoke = json.loads((out / (role + '-smoke/report.json')).read_text())
+ if not smoke['complete'] or not smoke['wrong_output_rejected']:
+ raise ValueError('incomplete retained compiler smoke')
+ report['toolchains'].append(dict(role=role, artifacts=fresh['artifacts'],
+ acceptance_sha256=sha(acceptance), runtime_sha256=sha(runtime),
+ fresh_pair_audits=fresh['audits'], portable_cli=True,
+ complete_results=len(smoke['cases']), scoped_map_manifest=new))
+ support = config['support']
+ runtime = next(t['runtime'] for t in config['toolchains'] if t['role'] == support['toolchain'])
+ publication = out / 'support-reproduced'
+ command = [sys.executable, str(REPO / 'test/repair_support_fixture.py'), runtime,
+ str(root / support['preparation']), '--output-directory', str(publication),
+ '--capabilities', '2', '--verification-memory-mib', '8192']
+ if support.get('relocate_patterns'):
+ command += ['--relocate-patterns']
+ run('support', command, seconds=1500)
+ compared = {}
+ for name in ['support-library.arboricx', 'support-rebuilt.arboricx', 'support-library.v1']:
+ if sha(publication / name) != sha(REPO / 'test/fixtures' / name):
+ raise ValueError('current support fixture did not reproduce: ' + name)
+ compared[name] = sha(publication / name)
+ expected = REPO / 'test/fixtures/support-receipts'
+ actual = publication / 'support-receipts'
+ if {p.name: sha(p) for p in actual.iterdir()} != {p.name: sha(p) for p in expected.iterdir()}:
+ raise ValueError('support receipt/manifests did not reproduce')
+ run('fixture-freshness', [sys.executable, str(REPO / 'test/fixture_sources.py'), 'current'])
+ report.update(complete=True, support_sha256=compared,
+ support_receipts_reproduced=True, fixture_sources_current=True)
+ finally:
+ save(out / 'report.json', report)
+ print('PASS isolated pair audits, CLI integrity/diagnostics, correctness smokes, scoped map-builds and support reproduction')
+
+
+if __name__ == '__main__':
+ main()
diff --git a/test/bootstrap_retention_test.py b/test/bootstrap_retention_test.py
@@ -0,0 +1,178 @@
+#!/usr/bin/env python3
+"""Filesystem safety controls for the opt-in checkpoint retention command."""
+import copy
+import fcntl
+import json
+import os
+from pathlib import Path
+import subprocess
+import sys
+import tempfile
+import unittest
+from unittest.mock import patch
+
+import bootstrap_retention as r
+from bootstrap_retention_lock import acquire, protect_generations
+
+
+class RetentionTests(unittest.TestCase):
+ def setUp(self):
+ self.temp = tempfile.TemporaryDirectory(); self.addCleanup(self.temp.cleanup)
+ self.root = Path(self.temp.name) / '.bootstrap-run'; self.root.mkdir()
+ self.keep = self.root / 'current'; self.keep.mkdir()
+ (self.keep / 'compiler').write_bytes(b'protected compiler')
+ self.old = self.root / 'old/cas/objects/abc'; self.old.mkdir(parents=True)
+ (self.old / 'tree').write_bytes(b'old tree')
+ (self.old / 'receipt').write_bytes(b'arboricx.build-receipt.v1\nsource\t123\n')
+ self.out = self.root / 'checkpoint'; self.out.mkdir()
+ self.config = dict(protected=['current'])
+
+ def plan(self):
+ plan = r.make_plan(self.root, self.config, self.out)
+ r.save(self.out / 'plan.json', plan)
+ return plan
+
+ def test_only_objects_and_preserved_evidence(self):
+ (self.root / 'old/status.json').write_text('{"complete": true}')
+ (self.root / 'old/source.tri').write_text('t')
+ plan = self.plan()
+ self.assertEqual([x['objects'] for x in plan['stores']], ['old/cas/objects'])
+ r.prune(self.root, plan, self.out)
+ self.assertFalse(self.old.exists())
+ self.assertTrue((self.keep / 'compiler').exists())
+ self.assertTrue((self.root / 'old/status.json').exists())
+ self.assertTrue((self.root / 'old/source.tri').exists())
+ self.assertFalse(plan['complete']) # postchecks are mandatory
+ marker = json.loads((self.root / 'old/cas/PRUNED-CAS.json').read_text())
+ self.assertFalse(marker['resumable'])
+ self.assertEqual(len(list((self.out / 'historical-metadata').iterdir())), 1)
+
+ def test_symlink_target_rejected(self):
+ (self.root / 'bad').mkdir()
+ (self.root / 'bad/objects').symlink_to(self.old.parent, target_is_directory=True)
+ with self.assertRaises(ValueError): self.plan()
+
+ def test_symlink_object_rejected(self):
+ (self.old / 'link').symlink_to(self.keep / 'compiler')
+ with self.assertRaises(ValueError): self.plan()
+
+ def test_path_escape_and_normalization(self):
+ for relative in ['../outside', '/tmp/objects', 'old/../current', 'old//cas', '']:
+ with self.assertRaises(ValueError): r.within(self.root, relative)
+
+ def test_mutated_candidate_blocks_all_deletion(self):
+ plan = self.plan(); (self.old / 'tree').write_bytes(b'changed tree')
+ with self.assertRaises(ValueError): r.prune(self.root, plan, self.out)
+ self.assertTrue((self.old / 'receipt').exists())
+
+ def test_mutated_protected_input_blocks_deletion(self):
+ plan = self.plan(); (self.keep / 'compiler').write_bytes(b'changed')
+ with self.assertRaises(ValueError): r.prune(self.root, plan, self.out)
+ self.assertTrue(self.old.exists())
+
+ def test_corrupt_metadata_blocks_deletion(self):
+ plan = self.plan()
+ next((self.out / 'historical-metadata').iterdir()).write_bytes(b'corrupt')
+ with self.assertRaises(ValueError): r.prune(self.root, plan, self.out)
+ self.assertTrue(self.old.exists())
+
+ def test_protected_target_injected_in_plan_rejected(self):
+ (self.keep / 'objects').mkdir()
+ plan = self.plan()
+ plan['stores'].append(dict(objects='current/objects', files={}, metadata=[], state='pending'))
+ with self.assertRaises(ValueError): r.prune(self.root, plan, self.out)
+ self.assertTrue(self.old.exists())
+
+ def test_hardlink_reclaim_and_retained_identity(self):
+ os.link(self.keep / 'compiler', self.old / 'shared')
+ external = Path(self.temp.name) / 'external'; os.link(self.old / 'tree', external)
+ plan = self.plan()
+ expected = (self.old / 'receipt').stat().st_blocks * 512
+ self.assertEqual(plan['reclaim_bytes'], expected)
+ r.prune(self.root, plan, self.out)
+ self.assertEqual((self.keep / 'compiler').read_bytes(), b'protected compiler')
+ self.assertEqual(external.read_bytes(), b'old tree')
+
+ def test_interrupted_directory_resumption(self):
+ plan = self.plan()
+ plan['stores'][0]['state'] = 'deleting'
+ (self.old / 'tree').unlink() # interrupted shutil.rmtree
+ r.prune(self.root, plan, self.out)
+ self.assertEqual(plan['stores'][0]['state'], 'removed')
+ r.prune(self.root, plan, self.out) # idempotent before postchecks
+
+ def test_missing_pending_directory_not_resumption(self):
+ plan = self.plan()
+ import shutil
+ shutil.rmtree(self.old.parent)
+ with self.assertRaises(ValueError): r.validate_plan(self.root, plan, self.out)
+
+ def test_removed_directory_reappearance_rejected(self):
+ plan = self.plan(); r.prune(self.root, plan, self.out)
+ self.old.mkdir(parents=True)
+ with self.assertRaises(ValueError): r.validate_plan(self.root, plan, self.out)
+
+ def test_tool_changed_plan_rejected(self):
+ plan = self.plan(); plan['tool_sha256'] = '0' * 64
+ with self.assertRaises(ValueError): r.validate_plan(self.root, plan, self.out)
+
+ def test_generation_cleanup_lock_exclusion(self):
+ handles = protect_generations([self.keep, self.old])
+ self.addCleanup(lambda: [h.close() for h in handles])
+ with self.assertRaises(ValueError): acquire(self.root, exclusive=True)
+ for h in handles: h.close()
+ lock = acquire(self.root, exclusive=True); self.addCleanup(lock.close)
+ with self.assertRaises(ValueError): protect_generations([self.keep])
+
+ def test_generation_runner_rejects_pruned_input_and_output(self):
+ plan = self.plan(); r.prune(self.root, plan, self.out)
+ script = r.REPO / 'test/bootstrap_generations.py'
+ for args in [[str(self.root / 'old'), '--bootstrap-kit', str(self.root / 'kit')],
+ [str(self.root / 'old'), str(self.root / 'fresh')]]:
+ reply = subprocess.run([sys.executable, str(script), *args],
+ capture_output=True, text=True, timeout=30)
+ self.assertNotEqual(reply.returncode, 0)
+ self.assertIn('pruned generations are historical evidence', reply.stderr)
+ self.assertFalse((self.root / 'old/status.json').exists())
+ self.assertFalse((self.root / 'fresh').exists())
+
+ def test_pending_checkpoint_cannot_pass_completion_gate(self):
+ plan = self.plan()
+ with self.assertRaises(ValueError): r.validate_completion(self.root, plan, self.out)
+ r.prune(self.root, plan, self.out)
+ with self.assertRaises(ValueError): r.validate_completion(self.root, plan, self.out)
+
+ def test_completed_checkpoint_requires_both_check_reports(self):
+ plan = self.plan(); r.prune(self.root, plan, self.out)
+ plan.update(complete=True, phase='complete', prechecks='missing-before', postchecks='missing-after')
+ with self.assertRaises(ValueError): r.validate_completion(self.root, plan, self.out)
+
+ def test_plan_cannot_create_evidence_inside_protected_inputs(self):
+ evidence = self.keep / 'must-not-create'
+ args = ['bootstrap_retention.py', 'plan', '--root', str(self.root),
+ '--directory', str(evidence), '--config', str(self.root / 'config.json')]
+ with patch('sys.argv', args), patch.object(r, 'load_config', return_value=self.config):
+ with self.assertRaises(ValueError): r.main()
+ self.assertFalse(evidence.exists())
+
+ def test_archived_harness_allows_non_destructive_new_version_check(self):
+ plan = self.plan()
+ original_sha = self.original_sha
+ with patch.object(r, 'sha', side_effect=lambda p: 'changed' if Path(p) == Path(r.__file__) else original_sha(p)):
+ with self.assertRaises(ValueError): r.validate_plan(self.root, plan, self.out)
+ r.validate_plan(self.root, plan, self.out, same_tools=False)
+ (self.out / 'harness/bootstrap_retention.py').write_bytes(b'corrupt')
+ with self.assertRaises(ValueError): r.validate_plan(self.root, plan, self.out, same_tools=False)
+
+ original_sha = staticmethod(r.sha)
+
+ def test_symlink_retained_directory_is_fingerprinted(self):
+ (self.keep / 'runtime').symlink_to('/nix/store/one', target_is_directory=True)
+ plan = self.plan()
+ (self.keep / 'runtime').unlink()
+ (self.keep / 'runtime').symlink_to('/nix/store/two', target_is_directory=True)
+ with self.assertRaises(ValueError): r.validate_plan(self.root, plan, self.out)
+
+
+if __name__ == '__main__':
+ unittest.main()