Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion CHARTER.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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")
Expand All @@ -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:**
Expand All @@ -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.

Expand Down
9 changes: 8 additions & 1 deletion docs/concepts/leases.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,14 +38,21 @@ 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
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

Expand Down
7 changes: 4 additions & 3 deletions docs/guides/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
18 changes: 13 additions & 5 deletions src/interlock/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -19,6 +19,7 @@
FaultActive,
InterlockError,
LeaseExpired,
LeaseNotIssued,
LeaseRevoked,
)
from interlock.claims import Claim
Expand All @@ -43,6 +44,7 @@
"Lease",
"LeaseExpired",
"LeaseManager",
"LeaseNotIssued",
"LeaseRevoked",
"Ledger",
"__version__",
Expand Down Expand Up @@ -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.
Expand All @@ -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):
Expand Down
4 changes: 4 additions & 0 deletions src/interlock/_errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down
89 changes: 75 additions & 14 deletions src/interlock/leases.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,19 @@
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
from collections.abc import Callable
from dataclasses import dataclass
from uuid import uuid4

from interlock._errors import LeaseExpired, LeaseRevoked
from interlock._errors import LeaseExpired, LeaseNotIssued, LeaseRevoked


@dataclass
Expand All @@ -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."""
Expand All @@ -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,
Expand All @@ -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
Loading
Loading