Add one-command owner envelope renewal (REINAH-WP-0008-T03)
Some checks failed
Governed runtime contract / contract (push) Failing after 12s

owner renew validates a settled ledger, builds the new spend policy from an
envelope file of changed fields only, retires the old ledger by rename,
initializes a fresh one at the same path and rewrites the policy and
owner.json pins. Dry run by default; prints the Secrets Engine file pins.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 269692@bnt-lap001
Assistant-Session: 8077e68a-0985-4b41-898c-2dc602b0d6be
This commit is contained in:
tegwick 2026-10-13 23:24:24 +02:00
parent caaea1e90e
commit b48c715e9a
6 changed files with 361 additions and 4 deletions

View file

@ -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`

View file

@ -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/<digest> --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 `<name>.<old envelope>.bak`;
- renames the old ledger to `<ledger>.retired-<old envelope>`;
- 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

View file

@ -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)

View file

@ -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",
}

118
tests/test_owner_renew.py Normal file
View file

@ -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"] == []

View file

@ -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-<old envelope>`, 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