Add one-command owner envelope renewal (REINAH-WP-0008-T03)
Some checks failed
Governed runtime contract / contract (push) Failing after 12s
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:
parent
caaea1e90e
commit
b48c715e9a
6 changed files with 361 additions and 4 deletions
|
|
@ -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`
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
161
rein_aharness/owner_renew.py
Normal file
161
rein_aharness/owner_renew.py
Normal 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
118
tests/test_owner_renew.py
Normal 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"] == []
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue