diff --git a/.custodian-brief.md b/.custodian-brief.md index bd1bee5..45c2340 100644 --- a/.custodian-brief.md +++ b/.custodian-brief.md @@ -2,20 +2,19 @@ # Custodian Brief — rein-aharness **Domain:** agents -**Last synced:** 2026-10-13 20:53 UTC +**Last synced:** 2026-10-13 21:14 UTC **State Hub:** http://127.0.0.1:8000 *(adjust if running on a remote machine)* ## Active Workstreams ### Simpler metered retry: free rehearsal, zero-cost hold release, one-command renewal -Progress: 0/7 done | workplan_id: `187c2599-84a6-5764-a14f-7e9121975bf2` +Progress: 1/7 done | workplan_id: `187c2599-84a6-5764-a14f-7e9121975bf2` **Open tasks:** - ! C. Envelopes that cover several attempts (decision) `898e02af` - ! E. Ship the owner runtime as a container image (decision) `b486e86b` - ! F. Approvals per envelope (decision) `4cf721e6` - ! Retry on the corrected runtime `80318842` -- · A. Free rehearsal on the host `c6536c8d` - · B. Release holds that provably cost nothing `6a413f5a` - · D. One owner command for renewal `87457216` diff --git a/docs/owner-bootstrap.md b/docs/owner-bootstrap.md index 8477769..084721f 100644 --- a/docs/owner-bootstrap.md +++ b/docs/owner-bootstrap.md @@ -23,6 +23,30 @@ first line of each launcher; the rehearsal executes them. Run 6efa9436 python -I -B -m rein_aharness.cli metered-once --owner-config /abs/owner.json --rehearse +**Renew an envelope with one command.** After the old envelope is +reconciled (no `held` reservation, no open request route), write a small +JSON file with only the fields that change: `envelope_id` (required and new), +plus any of `authority_ref`, `valid_from`, `expires_at`, `eur_per_usd`, +`per_run_eur`, `daily_eur`, `total_eur`, `max_budget_usd` and +`max_liability_usd`. Then: + + python -I -B -m rein_aharness.cli owner renew --owner-config /abs/owner.json \ + --policy /abs/spend-policy.json --ledger /abs/spend.sqlite3 \ + --runtime-path /abs/runtimes/ --envelope envelope.json + +Without `--apply` this only prints the summary: envelope, policy and runtime +digests from old to new, the limits, and where the old ledger goes. With +`--apply` it: + +- backs up `owner.json` and the policy as `..bak`; +- renames the old ledger to `.retired-`; +- initializes a fresh ledger at the same path; +- writes the new policy and `owner.json` privately and atomically. + +No digest is typed by hand. Paths do not change, so the Secrets Engine +`exec_owner` catalog needs only the new file hashes, which are printed as +`catalog_file_pins`. Update those pins, then run `--rehearse`. + Use the standalone candidate interpreter as `python -I -B -m rein_aharness.cli`. `-I` excludes editable PYTHONPATH/user-site fallbacks; `-B` prevents bytecode writes from changing the complete artifact digest. The source and namespace rein commands diff --git a/rein_aharness/cli.py b/rein_aharness/cli.py index 08bf89f..7c70d2d 100644 --- a/rein_aharness/cli.py +++ b/rein_aharness/cli.py @@ -611,6 +611,17 @@ def main(argv: list[str] | None = None) -> int: help="Skip the read-only Activity Core queue probe", ) + owner = sub.add_parser("owner", help="Owner actions on the metered runtime") + owner.add_argument("action", choices=("renew",)) + owner.add_argument("--owner-config", type=Path, required=True) + owner.add_argument("--policy", type=Path, required=True) + owner.add_argument("--ledger", type=Path, required=True) + owner.add_argument("--runtime-path", type=Path, required=True) + owner.add_argument("--envelope", type=Path, required=True, + help="JSON with only the envelope fields that change") + owner.add_argument("--apply", action="store_true", + help="Write the renewal; without it, print the summary only") + spend = sub.add_parser("spend", help="Inspect or reconcile private worker spend reservations") spend.add_argument("action", choices=("init", "status", "reconcile")) spend.add_argument("--policy", type=Path, required=True) @@ -638,6 +649,20 @@ def main(argv: list[str] | None = None) -> int: if args.command == "claim-loop": return _cmd_claim_loop(args) + if args.command == "owner": + from rein_aharness.owner_renew import RenewalRefused, apply, plan, summary + try: + renewal = plan(args.owner_config, args.policy, args.ledger, + args.runtime_path, args.envelope) + result = {"ok": True, "dry_run": not args.apply, **summary(renewal)} + if args.apply: + result.update(apply(renewal)) + except RenewalRefused as exc: + print(json.dumps({"ok": False, "refused": str(exc)})) + return 2 + print(json.dumps(result, indent=2)) + return 0 + if args.command == "close-outbox": return _cmd_close_outbox(args) diff --git a/rein_aharness/owner_renew.py b/rein_aharness/owner_renew.py new file mode 100644 index 0000000..59e5b39 --- /dev/null +++ b/rein_aharness/owner_renew.py @@ -0,0 +1,161 @@ +"""One owner command to renew a metered envelope (REINAH-WP-0008-T03). + +Renewal used to mean editing the spend policy, initializing a ledger and +updating five digests in owner.json by hand. This command does it in one +reviewed step: dry run by default, ``apply`` only on request. + +Paths stay the same so that the Secrets Engine catalog only needs new file +hashes, not new paths. The old ledger is renamed, never reset; the old policy +and owner.json are kept as backups. +""" + +from __future__ import annotations + +import hashlib +import json +import os +import sqlite3 +from dataclasses import replace +from pathlib import Path +from typing import Any + +from rein_aharness.request_admission import RequestLedger +from rein_aharness.spend_admission import ( + SpendAdmissionError, + SpendLedger, + SpendPolicy, + _private_file, +) + +# Fields an envelope may change. Worker, target, profile and grant pins carry +# over unchanged; changing those is a different admission, not a renewal. +ENVELOPE_FIELDS = frozenset({ + "envelope_id", "authority_ref", "valid_from", "expires_at", + "eur_per_usd", "per_run_eur", "daily_eur", "total_eur", + "max_budget_usd", "max_liability_usd", +}) +_OWNER_KEYS = {"version", "authority_ref", "spend_policy_sha256", "messages_policy", "runtime"} + + +class RenewalRefused(RuntimeError): + pass + + +def _file_sha256(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def _write_private(path: Path, text: str) -> None: + temporary = path.with_name(f".{path.name}.renew") + fd = os.open(temporary, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600) + with os.fdopen(fd, "w") as stream: + stream.write(text) + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, path) + + +def _ledger_settled(ledger: Path) -> None: + for suffix in ("-journal", "-wal"): + if Path(f"{ledger}{suffix}").exists(): + raise RenewalRefused("ledger has an open transaction") + db = sqlite3.connect(f"file:{ledger}?mode=ro", uri=True) + try: + if db.execute("SELECT count(*) FROM reservations WHERE state='held'").fetchone()[0]: + raise RenewalRefused("ledger has a held reservation; reconcile it first") + if db.execute("SELECT count(*) FROM request_routes WHERE revoked=0").fetchone()[0]: + raise RenewalRefused("ledger has an open request route") + except sqlite3.Error: + raise RenewalRefused("ledger is unreadable") from None + finally: + db.close() + + +def plan(owner_config: Path, policy_path: Path, ledger_path: Path, + runtime_path: Path, envelope_path: Path) -> dict[str, Any]: + """Validate everything and return the renewal; change nothing.""" + from sandboxer.extensions.runtime import runtime_digest + + for path in (owner_config, policy_path, ledger_path, runtime_path): + if not path.is_absolute() or path.resolve() != path: + raise RenewalRefused("paths must be absolute and canonical") + try: + _private_file(owner_config) + _private_file(ledger_path) + old_policy = SpendPolicy.load(policy_path) + except SpendAdmissionError as exc: + raise RenewalRefused(str(exc)) from None + owner = json.loads(owner_config.read_text()) + if set(owner) != _OWNER_KEYS or owner["spend_policy_sha256"] != old_policy.sha256: + raise RenewalRefused("owner.json does not match the current spend policy") + _ledger_settled(ledger_path) + + envelope = json.loads(envelope_path.read_text()) + if not isinstance(envelope, dict) or not envelope or set(envelope) - ENVELOPE_FIELDS: + raise RenewalRefused( + f"envelope may only set: {', '.join(sorted(ENVELOPE_FIELDS))}") + if envelope.get("envelope_id", old_policy.envelope_id) == old_policy.envelope_id: + raise RenewalRefused("renewal needs a new envelope_id") + try: + new_policy = replace(old_policy, **envelope) + except (SpendAdmissionError, TypeError) as exc: + raise RenewalRefused(f"new spend policy invalid: {exc}") from None + + if not (runtime_path / "bin").is_dir(): + raise RenewalRefused("runtime path is not a runtime") + runtime_sha256 = runtime_digest(runtime_path) + new_owner = {**owner, "authority_ref": new_policy.authority_ref, + "spend_policy_sha256": new_policy.sha256, + "runtime": {"path": str(runtime_path), "sha256": runtime_sha256}} + return { + "owner_config": owner_config, "policy_path": policy_path, "ledger_path": ledger_path, + "old_policy": old_policy, "new_policy": new_policy, + "old_owner": owner, "new_owner": new_owner, + "retired_ledger": ledger_path.with_name( + f"{ledger_path.name}.retired-{old_policy.envelope_id}"), + } + + +def summary(renewal: dict[str, Any]) -> dict[str, Any]: + old, new = renewal["old_policy"], renewal["new_policy"] + return { + "envelope": {"from": old.envelope_id, "to": new.envelope_id}, + "spend_policy_sha256": {"from": old.sha256, "to": new.sha256}, + "runtime_sha256": {"from": renewal["old_owner"]["runtime"]["sha256"], + "to": renewal["new_owner"]["runtime"]["sha256"]}, + "limits": {name: getattr(new, name) for name in sorted(ENVELOPE_FIELDS - {"envelope_id"})}, + "ledger": {"path": str(renewal["ledger_path"]), + "old_retired_to": str(renewal["retired_ledger"])}, + } + + +def apply(renewal: dict[str, Any]) -> dict[str, Any]: + """Back up, retire the old ledger, write new files; return catalog pins.""" + owner_config, policy_path, ledger_path = ( + renewal["owner_config"], renewal["policy_path"], renewal["ledger_path"]) + old_id = renewal["old_policy"].envelope_id + retired = renewal["retired_ledger"] + backups = [path.with_name(f"{path.name}.{old_id}.bak") for path in (owner_config, policy_path)] + if retired.exists() or any(path.exists() for path in backups): + raise RenewalRefused("a previous renewal from this envelope left files behind") + _ledger_settled(ledger_path) + + staged = ledger_path.with_name(f".{ledger_path.name}.renew") + if staged.exists(): + raise RenewalRefused("a staged ledger already exists") + new_ledger = SpendLedger(staged, renewal["new_policy"]) + new_ledger.initialize() + RequestLedger(new_ledger).initialize() + + for source, backup in zip((owner_config, policy_path), backups): + _write_private(backup, source.read_text()) + os.rename(ledger_path, retired) + os.rename(staged, ledger_path) + _write_private(policy_path, json.dumps(renewal["new_policy"].__dict__, indent=2) + "\n") + _write_private(owner_config, json.dumps(renewal["new_owner"], indent=2) + "\n") + return { + "applied": True, + "catalog_file_pins": {str(path): _file_sha256(path) for path in (owner_config, policy_path)}, + "next": "update the Secrets Engine exec_owner file pins, then run " + "metered-once --rehearse", + } diff --git a/tests/test_owner_renew.py b/tests/test_owner_renew.py new file mode 100644 index 0000000..45b6b81 --- /dev/null +++ b/tests/test_owner_renew.py @@ -0,0 +1,118 @@ +"""One-command owner renewal (REINAH-WP-0008-T03).""" + +from __future__ import annotations + +import hashlib +import json +import sqlite3 +from pathlib import Path + +import pytest + +from rein_aharness.cli import main +from rein_aharness.owner_bootstrap import prepare +from rein_aharness.owner_renew import RenewalRefused, apply, plan +from rein_aharness.spend_admission import SpendLedger, SpendPolicy +from test_owner_bootstrap import prepared as prepared +from test_spend_admission import NOW, ledger as ledger, run + +ENVELOPE = { + "envelope_id": "renewed-2026-10-13", + "authority_ref": "fixture:owner-renewal", + "total_eur": "30", +} + + +def _paths(prepared, tmp_path, envelope=ENVELOPE): + path, config, _, runtime = prepared + envelope_path = tmp_path / "envelope.json" + envelope_path.write_text(json.dumps(envelope)) + return (path, Path(config.spend_policy_path), Path(config.spend_ledger_path), + runtime, envelope_path) + + +def _digests(*paths): + return {p: hashlib.sha256(p.read_bytes()).hexdigest() for p in paths} + + +def test_dry_run_changes_nothing(prepared, tmp_path, capsys): + paths = _paths(prepared, tmp_path) + before = _digests(*paths[:3]) + argv = ["owner", "renew", "--owner-config", str(paths[0]), "--policy", str(paths[1]), + "--ledger", str(paths[2]), "--runtime-path", str(paths[3]), + "--envelope", str(paths[4])] + assert main(argv) == 0 + out = json.loads(capsys.readouterr().out) + assert out["dry_run"] is True and "catalog_file_pins" not in out + assert out["envelope"] == {"from": "test-only", "to": "renewed-2026-10-13"} + assert out["limits"]["total_eur"] == "30" + assert _digests(*paths[:3]) == before + + +def test_apply_renews_and_owner_check_passes(prepared, tmp_path): + path, config, _, runtime = prepared + paths = _paths(prepared, tmp_path) + old_ledger = SpendLedger(paths[2], SpendPolicy.load(paths[1])) + result = apply(plan(*paths)) + + new_policy = SpendPolicy.load(paths[1]) + owner = json.loads(paths[0].read_text()) + assert new_policy.envelope_id == "renewed-2026-10-13" and new_policy.total_eur == "30" + assert owner["spend_policy_sha256"] == new_policy.sha256 + assert owner["authority_ref"] == "fixture:owner-renewal" + assert owner["runtime"]["path"] == str(runtime) + # The real owner check accepts the renewed files at the unchanged paths. + prepare(path, config) + # Fresh ledger for the new envelope; the old one is retired, not reset. + assert SpendLedger(paths[2], new_policy).status()["reservations"] == [] + retired = paths[2].with_name(f"{paths[2].name}.retired-test-only") + assert retired.exists() + assert paths[0].with_name("owner.json.test-only.bak").exists() + assert result["catalog_file_pins"] == { + str(p): d for p, d in _digests(paths[0], paths[1]).items()} + assert oct(paths[1].stat().st_mode & 0o777) == "0o600" + + +def test_held_reservation_refuses(prepared, tmp_path): + paths = _paths(prepared, tmp_path) + db = sqlite3.connect(paths[2]) + db.execute("INSERT INTO reservations (run_id, definition_id, idempotency_key, attempt, state, " + "liability, start_day) VALUES ('r', 'd', 'k', 1, 'held', 1, '2026-10-13')") + db.commit(); db.close() + with pytest.raises(RenewalRefused, match="held reservation"): + plan(*paths) + + +@pytest.mark.parametrize("envelope,match", [ + ({"total_eur": "30"}, "new envelope_id"), + ({**ENVELOPE, "envelope_id": "test-only"}, "new envelope_id"), + ({**ENVELOPE, "worker_id": "other"}, "envelope may only set"), + ({**ENVELOPE, "total_eur": "1"}, "new spend policy invalid"), +]) +def test_bad_envelope_refuses(prepared, tmp_path, envelope, match): + with pytest.raises(RenewalRefused, match=match): + plan(*_paths(prepared, tmp_path, envelope)) + + +def test_second_apply_from_same_envelope_refuses(prepared, tmp_path): + paths = _paths(prepared, tmp_path) + renewal = plan(*paths) + apply(renewal) + with pytest.raises(RenewalRefused): + apply(renewal) + + +def test_charged_reservation_renews_and_survives_in_retired_ledger(prepared, tmp_path): + # The railiance01 state after the 2026-10-13 reconciliation. + paths = _paths(prepared, tmp_path) + db = sqlite3.connect(paths[2]) + db.execute("INSERT INTO reservations (run_id, definition_id, idempotency_key, attempt, state, " + "liability, start_day, end_day, observed_usd, receipt) VALUES ('6efa9436', 'd', 'k', " + "1, 'charged', 10000000, '2026-09-27', '2026-10-13', '0.05', 'owner-estimate')") + db.commit(); db.close() + apply(plan(*paths)) + retired = sqlite3.connect(paths[2].with_name(f"{paths[2].name}.retired-test-only")) + assert retired.execute("SELECT state, liability FROM reservations").fetchall() == [ + ("charged", 10000000)] + retired.close() + assert SpendLedger(paths[2], SpendPolicy.load(paths[1])).status()["reservations"] == [] diff --git a/workplans/REINAH-WP-0008-simpler-metered-retry.md b/workplans/REINAH-WP-0008-simpler-metered-retry.md index 633f080..f2cafa1 100644 --- a/workplans/REINAH-WP-0008-simpler-metered-retry.md +++ b/workplans/REINAH-WP-0008-simpler-metered-retry.md @@ -117,7 +117,7 @@ release. ```task id: REINAH-WP-0008-T03 -status: todo +status: done priority: high state_hub_task_id: "87457216-2371-51ca-b3d4-3917e6f180a2" ``` @@ -139,6 +139,36 @@ rehearsal. The owner check passes on the result. A second renew with an unsettled reservation refuses. No digest has to be typed by hand. +### Implemented — 2026-10-13 + +The command is `rein-aharness owner renew --owner-config --policy --ledger +--runtime-path --envelope [--apply]`, in `rein_aharness/owner_renew.py`. +Without `--apply` it is a dry run. Choices: + +- **Envelope file:** only the envelope fields may change. Worker, target, + profile and grant pins carry over, and `SpendPolicy` validation runs on + the result. +- **Paths stay the same:** the old ledger is renamed to + `.retired-`, never reset, and the old policy and owner.json + are kept as `.bak`. Secrets Engine pins the `exec_owner` files by sha256 + and the ledger path through its fixed environment, so keeping the paths + means the catalog needs only new file hashes. The command prints them. +- **Refusals:** a held reservation, an open request route, an open SQLite + transaction, an owner.json that doesn't match the policy, a reused + envelope id, a disallowed field, or leftovers from an earlier renewal. + +Tests (`tests/test_owner_renew.py`): + +- A dry run changes nothing. +- An applied renewal passes the real owner check (`prepare`) at the + unchanged paths. +- A charged reservation, the railiance01 state after the 2026-10-13 + reconciliation, renews, and the row survives in the retired ledger. +- Bad envelopes and a second apply both refuse. + +Finding for T06: renewal still changes the pinned file hashes, so each +renewal needs a Secrets Engine catalog update and fresh approvals. + ## C. Envelopes that cover several attempts (decision) ```task