diff --git a/CHANGELOG.md b/CHANGELOG.md index ed9e1b2..aa1faf9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,18 @@ the current version is `interlock.__version__` (mirrored into `VERSION` by crashing before `close()`; before, records sat in the process buffer and died with it. `close()` fsyncs a file the ledger opened itself. Regression tests: `tests/test_ledger_durability.py`. +- `Interlock.permit()` rejects any lease it didn't issue. Before, a hand-built `Lease` with a + far-future `expires_at` passed the gate. `LeaseManager` now keeps its own record of every + lease it grants, and that record decides expiry and revocation. A hand-built lease, a + lease from another manager, or a lease whose resource, holder, TTL, or grant time was + edited after issue raises `LeaseNotIssued`, and the denial is logged with reason + `lease_not_issued`. Editing `expires_at` or clearing `revoked` on a `Lease` object no + longer extends or restores authority. `keepalive()` rejects unissued leases. Regression + tests: `tests/test_lease_issuance.py`. + +### Added +- `interlock.LeaseNotIssued`, a subclass of `InterlockError`, plus `LeaseManager.issued()` + and `LeaseManager.is_revoked()`. ## [0.1.0] — 2026-09-27 ### Added diff --git a/CHARTER.md b/CHARTER.md index 9255052..acba645 100644 --- a/CHARTER.md +++ b/CHARTER.md @@ -51,7 +51,8 @@ an unexercised grant persists. To act, the agent presents the lease and requests a permit for a specific action on a specific target. The interlock checks, in order: is the e-stop -clear? Is the lease live (held, unexpired, keepalive-fresh)? Is there an +clear? Is the lease live (issued by this interlock, unaltered, unexpired, +keepalive-fresh)? Is there an active fault gating this resource? Only if all three pass is the permit issued — and the permit covers exactly the requested action, nothing more. diff --git a/README.md b/README.md index da61a38..e366cf5 100644 --- a/README.md +++ b/README.md @@ -13,11 +13,12 @@ Mobile robots solved this a decade ago. Boston Dynamics' Spot admits intent thro Inside the substrate, **no action executes without a live lease, a clear e-stop, and no gating fault.** `Interlock.permit()` checks in this order and raises on the first failure: 1. e-stop clear, else `EStopEngaged` -2. lease held, unrevoked, and unexpired, else `LeaseRevoked` or `LeaseExpired` +2. lease issued by this `Interlock` and unaltered, else `LeaseNotIssued`; then unrevoked and unexpired, else `LeaseRevoked` or `LeaseExpired` 3. no `major` or `critical` fault on the lease's resource, else `FaultActive` - **Authority expires.** A lease exists only from `grant()` and dies at `expires_at` unless a keepalive renews it. - **A keepalive cannot resurrect a dead lease.** Renewal after expiry or revocation raises. +- **Only issued leases pass.** The lease manager keeps its own record of every lease it grants, and that record decides expiry and revocation. Editing a `Lease` object grants nothing. - **Revocation is permanent.** - **E-stop release is an operator decision, never a timeout.** One engagement refuses every permit until an explicit `release()`. - **Every permit and every denial is recorded** in the ledger's `permits` or `denials` series, with the reason. @@ -39,7 +40,7 @@ lease = ilk.grant(resource="docs:write", holder="research-agent", ttl=1800) # EXERCISE: every action passes the gate. with ilk.permit(lease, action="write", target="brief.md"): - ... # raises EStopEngaged, LeaseRevoked, LeaseExpired, or FaultActive otherwise + ... # raises EStopEngaged, LeaseNotIssued, LeaseRevoked, LeaseExpired, or FaultActive otherwise # A major or critical fault refuses further permits on the affected resource. ilk.faults.report(code="TOOL_TIMEOUT", severity="major", resource="web:read") @@ -62,7 +63,8 @@ The fastest path to a first lease is [`docs/guides/quickstart.md`](docs/guides/q |---|---| | E-stop engaged | Every `permit()` raises `EStopEngaged` and logs a `denials` record | | Lease expired (no keepalive in time) | `LeaseExpired`. A late keepalive also raises | -| Lease revoked | `LeaseRevoked`, permanently | +| Lease not issued by this `Interlock` (hand-built, from another instance, or edited after issue) | `LeaseNotIssued` and a `denials` record with reason `lease_not_issued` | +| Lease revoked | `LeaseRevoked`, permanently. Resetting `lease.revoked` on the object does not restore it | | `major`/`critical` fault on the resource | `FaultActive` until the fault is cleared. `minor` faults are recorded but do not gate | **Known limits:** @@ -74,7 +76,7 @@ The fastest path to a first lease is [`docs/guides/quickstart.md`](docs/guides/q ## Evidence -- **59 tests pass:** `pytest -q`, run 2026-10-07 on `main`. CI runs the same suite on Python 3.11, 3.12, and 3.13, plus `ruff` lint and format checks and a version-consistency check. +- **68 tests pass:** `pytest -q`, run 2026-10-07 on `main`. CI runs the same suite on Python 3.11, 3.12, and 3.13, plus `ruff` lint and format checks and a version-consistency check. - The quickstart above was executed on 2026-10-07: the gate refused a permit with `FaultActive` after a `major` fault. - [`INTAKE_LEDGER.md`](INTAKE_LEDGER.md) lists every source behind the transfer, with reading depth and an explicit unverified list. diff --git a/docs/concepts/leases.md b/docs/concepts/leases.md index 79155dd..fce8df6 100644 --- a/docs/concepts/leases.md +++ b/docs/concepts/leases.md @@ -38,6 +38,12 @@ decays, and nothing about an unexercised grant persists. Revocation is the deliberate counterpart: immediate, permanent, and checked before expiry in every gate. +The manager keeps its own record of every lease it grants, and that record +decides expiry and revocation. A hand-built `Lease`, a lease from another +manager, or a lease whose resource, holder, TTL, or grant time was edited +after issue fails with `LeaseNotIssued`. Pushing `expires_at` forward or +clearing `revoked` on the object grants nothing. + ## Safety Leases are designed to reduce the risk of authority outliving its need. They @@ -45,7 +51,8 @@ are not designed to eliminate it. A lease system cannot constrain code that bypasses it, and a TTL cannot help if the TTL is a year. Operators SHOULD treat short TTLs and frequent keepalives as the normal posture, not as paranoia. The checkable guarantee is narrower: *within* the substrate, no -permit issues against an expired or revoked lease, and the tests cover it. +permit issues against a lease the manager did not issue, or against an +expired or revoked lease. The tests cover both. ## A lease, end to end diff --git a/docs/guides/quickstart.md b/docs/guides/quickstart.md index f333919..d00e8bf 100644 --- a/docs/guides/quickstart.md +++ b/docs/guides/quickstart.md @@ -30,9 +30,10 @@ with ilk.permit(lease, action="write", target="notes.md"): Path("notes.md").write_text("hello") ``` -The gate checks, in order: e-stop clear, lease live, no gating fault on -`docs:write`. If any check fails, the context manager raises instead of -yielding — `EStopEngaged`, `LeaseExpired`/`LeaseRevoked`, or `FaultActive`. +The gate checks, in order: e-stop clear, lease issued by this `Interlock` +and live, no gating fault on `docs:write`. If any check fails, the context +manager raises instead of yielding — `EStopEngaged`, `LeaseNotIssued`, +`LeaseExpired`/`LeaseRevoked`, or `FaultActive`. ## 4. Watch a fault refuse diff --git a/src/interlock/__init__.py b/src/interlock/__init__.py index edfc89e..9e8e26f 100644 --- a/src/interlock/__init__.py +++ b/src/interlock/__init__.py @@ -7,8 +7,8 @@ - ``.faults`` — the shared :class:`~interlock.faults.FaultBus`. - ``.ledger`` — the self-describing :class:`~interlock.ledger.Ledger`. -Every permit passes the gate in order: e-stop clear, lease live, no gating -fault on the lease's resource. +Every permit passes the gate in order: e-stop clear, lease issued by this +facade and live, no gating fault on the lease's resource. """ from collections.abc import Callable, Iterator @@ -19,6 +19,7 @@ FaultActive, InterlockError, LeaseExpired, + LeaseNotIssued, LeaseRevoked, ) from interlock.claims import Claim @@ -43,6 +44,7 @@ "Lease", "LeaseExpired", "LeaseManager", + "LeaseNotIssued", "LeaseRevoked", "Ledger", "__version__", @@ -95,8 +97,9 @@ def permit(self, lease: Lease, action: str, target: str) -> Iterator[Lease]: """Permit exactly one action on one target, checking in order: 1. e-stop clear → else :exc:`EStopEngaged` - 2. lease live (held, unrevoked, unexpired) → else :exc:`LeaseRevoked` - or :exc:`LeaseExpired` + 2. lease issued by this facade's manager and unaltered → else + :exc:`LeaseNotIssued`; then live (unrevoked, unexpired) → else + :exc:`LeaseRevoked` or :exc:`LeaseExpired` 3. no gating fault on the lease's resource → else :exc:`FaultActive` Permits and denials are appended to the ledger. @@ -113,7 +116,12 @@ def permit(self, lease: Lease, action: str, target: str) -> Iterator[Lease]: except EStopEngaged: self.ledger.append("denials", {**base, "reason": "estop"}) raise - if lease.revoked: + if not self.leases.issued(lease): + self.ledger.append("denials", {**base, "reason": "lease_not_issued"}) + raise LeaseNotIssued( + f"lease {lease.id} for {lease.resource} was not issued by this Interlock" + ) + if self.leases.is_revoked(lease): self.ledger.append("denials", {**base, "reason": "lease_revoked"}) raise LeaseRevoked(f"lease {lease.id} for {lease.resource} was revoked") if not self.leases.is_live(lease): diff --git a/src/interlock/_errors.py b/src/interlock/_errors.py index 3b44965..3e0c89f 100644 --- a/src/interlock/_errors.py +++ b/src/interlock/_errors.py @@ -14,6 +14,10 @@ class LeaseExpired(InterlockError): """A lease was exercised after its expiry without a keepalive renewal.""" +class LeaseNotIssued(InterlockError): + """A lease was presented that this lease manager never issued, or was altered after issue.""" + + class LeaseRevoked(InterlockError): """A lease was exercised after being revoked by its granter.""" diff --git a/src/interlock/leases.py b/src/interlock/leases.py index 4d05102..3504a0e 100644 --- a/src/interlock/leases.py +++ b/src/interlock/leases.py @@ -5,6 +5,11 @@ comes into existence; without keepalive renewal the lease expires, and an unexercised grant simply decays. The clock is injectable so tests can drive time deterministically. + +The manager keeps its own record of every lease it issued. That record, not +the caller's ``Lease`` object, is the source of truth for expiry and +revocation: a hand-built ``Lease``, a lease from another manager, or a lease +whose fields were edited after issue is not recognised as issued. """ import time @@ -12,7 +17,7 @@ from dataclasses import dataclass from uuid import uuid4 -from interlock._errors import LeaseExpired, LeaseRevoked +from interlock._errors import LeaseExpired, LeaseNotIssued, LeaseRevoked @dataclass @@ -29,11 +34,24 @@ class Lease: revoked: bool = False +@dataclass +class _Issued: + """The manager's authoritative record of one issued lease.""" + + resource: str + holder: str + ttl_seconds: float + granted_at: float + expires_at: float + revoked: bool = False + + class LeaseManager: """Issues, renews, revokes, and checks leases against an injectable clock.""" def __init__(self, clock: Callable[[], float] | None = None) -> None: self._clock = clock if clock is not None else time.monotonic + self._issued: dict[str, _Issued] = {} def now(self) -> float: """Current time according to the manager's clock.""" @@ -42,7 +60,7 @@ def now(self) -> float: def grant(self, resource: str, holder: str, ttl_seconds: float) -> Lease: """Create a new lease. This is the only moment authority comes into existence.""" now = self.now() - return Lease( + lease = Lease( id=uuid4().hex, resource=resource, holder=holder, @@ -51,29 +69,72 @@ def grant(self, resource: str, holder: str, ttl_seconds: float) -> Lease: expires_at=now + ttl_seconds, last_keepalive=now, ) + self._issued[lease.id] = _Issued( + resource=resource, + holder=holder, + ttl_seconds=ttl_seconds, + granted_at=now, + expires_at=lease.expires_at, + ) + return lease + + def _record(self, lease: Lease) -> _Issued | None: + rec = self._issued.get(lease.id) + if rec is None: + return None + if (rec.resource, rec.holder, rec.ttl_seconds, rec.granted_at) != ( + lease.resource, + lease.holder, + lease.ttl_seconds, + lease.granted_at, + ): + return None # same id, altered identity: not the lease that was issued + return rec + + def issued(self, lease: Lease) -> bool: + """True when this manager issued ``lease`` and its identity fields are unaltered.""" + return self._record(lease) is not None + + def is_revoked(self, lease: Lease) -> bool: + """True when ``lease`` was revoked, by the manager's record or the object's flag.""" + rec = self._record(lease) + return lease.revoked or (rec is not None and rec.revoked) def keepalive(self, lease: Lease) -> Lease: """Renew a lease: ``expires_at`` becomes now plus the original TTL. - Raises :exc:`LeaseRevoked` if the lease was revoked and - :exc:`LeaseExpired` if the renewal arrives after expiry. A renewal - cannot resurrect a dead lease. + Raises :exc:`LeaseNotIssued` if this manager never issued the lease, + :exc:`LeaseRevoked` if the lease was revoked, and :exc:`LeaseExpired` + if the renewal arrives after expiry. A renewal cannot resurrect a dead + lease. Expiry is judged against the manager's record, not the object. """ - if lease.revoked: + rec = self._record(lease) + if rec is None: + raise LeaseNotIssued(f"lease {lease.id} for {lease.resource} was not issued here") + if lease.revoked or rec.revoked: raise LeaseRevoked(f"lease {lease.id} for {lease.resource} was revoked") now = self.now() - if now > lease.expires_at: - raise LeaseExpired( - f"lease {lease.id} for {lease.resource} expired at {lease.expires_at}" - ) + if now > rec.expires_at: + raise LeaseExpired(f"lease {lease.id} for {lease.resource} expired at {rec.expires_at}") + rec.expires_at = now + rec.ttl_seconds lease.last_keepalive = now - lease.expires_at = now + lease.ttl_seconds + lease.expires_at = rec.expires_at return lease def revoke(self, lease: Lease) -> None: - """Revoke a lease immediately. Revocation is permanent.""" + """Revoke a lease immediately. Revocation is permanent. + + The manager records the revocation, so resetting ``lease.revoked`` on + the object does not restore authority. + """ lease.revoked = True + rec = self._record(lease) + if rec is not None: + rec.revoked = True def is_live(self, lease: Lease) -> bool: - """True when the lease is held, unrevoked, and not yet expired.""" - return not lease.revoked and self.now() <= lease.expires_at + """True when this manager issued the lease and it is unrevoked and unexpired.""" + rec = self._record(lease) + if rec is None: + return False + return not (lease.revoked or rec.revoked) and self.now() <= rec.expires_at diff --git a/tests/test_lease_issuance.py b/tests/test_lease_issuance.py new file mode 100644 index 0000000..02b8f5c --- /dev/null +++ b/tests/test_lease_issuance.py @@ -0,0 +1,101 @@ +"""Regression tests: the gate admits only leases it issued, unaltered and unrevoked. + +``LeaseNotIssued`` is looked up at call time so the core regression tests fail +on an assertion (not an ImportError) against code that predates the fix. +""" + +import dataclasses + +import pytest + +import interlock +from interlock import Interlock, InterlockError, Lease, LeaseExpired, LeaseRevoked + + +def _forged(resource="docs:write"): + return Lease( + id="forged", + resource=resource, + holder="anyone", + ttl_seconds=1, + granted_at=0, + expires_at=1e18, + last_keepalive=0, + ) + + +def _not_issued_error(): + return getattr(interlock, "LeaseNotIssued", None) + + +def test_permit_rejects_a_forged_lease(ilk): + ran = [] + with ( + pytest.raises(InterlockError) as info, + ilk.permit(_forged(), action="write", target="f.md"), + ): + ran.append(True) # pragma: no cover + assert ran == [], "the action body must never run under a forged lease" + assert type(info.value).__name__ == "LeaseNotIssued" + denials = list(ilk.ledger.read_series("denials")) + assert denials[-1]["reason"] == "lease_not_issued" + assert list(ilk.ledger.read_series("permits")) == [] + + +def test_permit_rejects_a_lease_from_another_interlock(clock): + issuer = Interlock(clock=clock) + gate = Interlock(clock=clock) + lease = issuer.grant("docs:write", "agent", ttl_seconds=300) + with pytest.raises(InterlockError) as info, gate.permit(lease, action="write", target="f"): + pass # pragma: no cover + assert type(info.value).__name__ == "LeaseNotIssued" + + +def test_permit_rejects_a_lease_retargeted_after_issue(ilk): + lease = ilk.grant("docs:read", "agent", ttl_seconds=300) + widened = dataclasses.replace(lease, resource="docs:write") # same id, new resource + with pytest.raises(InterlockError) as info, ilk.permit(widened, action="write", target="f"): + pass # pragma: no cover + assert type(info.value).__name__ == "LeaseNotIssued" + + +def test_editing_expires_at_does_not_extend_authority(ilk, clock): + lease = ilk.grant("docs:write", "agent", ttl_seconds=60) + lease.expires_at = 1e18 + clock.advance(61) + with pytest.raises(LeaseExpired), ilk.permit(lease, action="write", target="f"): + pass # pragma: no cover + + +def test_clearing_the_revoked_flag_does_not_restore_authority(ilk): + lease = ilk.grant("docs:write", "agent", ttl_seconds=300) + ilk.leases.revoke(lease) + lease.revoked = False + with pytest.raises(LeaseRevoked), ilk.permit(lease, action="write", target="f"): + pass # pragma: no cover + + +def test_keepalive_rejects_a_lease_it_never_issued(ilk): + with pytest.raises(InterlockError) as info: + ilk.leases.keepalive(_forged()) + assert type(info.value).__name__ == "LeaseNotIssued" + + +def test_keepalive_still_renews_issued_leases(ilk, clock): + lease = ilk.grant("docs:write", "agent", ttl_seconds=60) + clock.advance(50) + ilk.leases.keepalive(lease) + clock.advance(50) + with ilk.permit(lease, action="write", target="f"): + pass # renewed at t=50 in the manager's record, live until t=110 + + +def test_is_live_false_for_unissued_lease(ilk): + assert ilk.leases.is_live(_forged()) is False + assert ilk.leases.issued(ilk.grant("r", "h", ttl_seconds=5)) is True + + +def test_lease_not_issued_is_exported_and_derives_from_base(): + err = _not_issued_error() + assert err is not None and "LeaseNotIssued" in interlock.__all__ + assert issubclass(err, InterlockError)