tricu

An interpreted language for exploring Tree Calculus
Log | Files | Refs | README | LICENSE

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:
MAGENTS.md | 14++++++++++++++
Mdocs/compiler-rebuilding.md | 126+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mflake.nix | 8++++++++
Mtest/bootstrap_generations.py | 7+++++++
Atest/bootstrap_retention.py | 426+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atest/bootstrap_retention_lock.py | 31+++++++++++++++++++++++++++++++
Atest/bootstrap_retention_probe.py | 93+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atest/bootstrap_retention_test.py | 178+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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()