Skip to content

Reject leases the gate never issued - #7

Merged
FetchDoge merged 1 commit into
mainfrom
fix/reject-unissued-leases
Oct 7, 2026
Merged

FetchDoge merged 1 commit into
mainfrom
fix/reject-unissued-leases

Conversation

@FetchDoge

Copy link
Copy Markdown
Owner

What

Interlock.permit() now refuses any lease it didn't issue. LeaseManager keeps its own record of every lease it grants, and that record, not the caller's Lease object, decides whether a lease has expired or been revoked. A new exported exception, LeaseNotIssued(InterlockError), signals the refusal.

Why

permit() checked only the fields on the Lease object it was handed. The manager kept no record of what it had issued, so anyone could build a lease and pass the gate for any resource. Repro on main:

ilk = Interlock()
forged = Lease(id="forged", resource="github:x/y:repo-edit", holder="anyone",
               ttl_seconds=1, granted_at=0, expires_at=1e18, last_keepalive=0)
with ilk.permit(forged, "edit", "x/y"):
    print("forged lease permitted")   # prints on main

On main, pushing lease.expires_at forward also extended authority, and setting lease.revoked = False after revoke() restored it.

Guarantee

permit() checks the lease in this order: e-stop clear, then issued by this facade's LeaseManager with resource, holder, TTL and grant time unchanged, then unrevoked, then unexpired, then no gating fault.

  • A hand-built lease, a lease from another Interlock or LeaseManager, or a lease whose resource, holder, TTL or grant time was edited after issue raises LeaseNotIssued. The ledger logs a denials record with reason lease_not_issued, and the action body never runs.
  • Expiry and revocation come from the manager's record. Editing expires_at or clearing revoked on the object grants nothing.
  • keepalive() raises LeaseNotIssued for unissued leases. It still renews issued leases, and it updates both the record and the object.
  • is_live() returns False for unissued leases. New helpers issued() and is_revoked() are available.
  • Normal use, grant() then permit() or keepalive(), behaves as before. All 59 existing tests pass unchanged.

Not promised: the record lives in process memory and only covers leases this process issued, the same as the e-stop and fault bus. It doesn't defend against code that edits the manager's private _issued map or sidesteps permit() entirely. The record keeps every issued lease, so memory grows by one small entry per grant.

Failure behavior

Refusals raise LeaseNotIssued (an InterlockError) and write a ledger denial, so an operator sees reason: lease_not_issued. Code that used to pass a lease from one Interlock to another's permit() now gets refused. That's the intended break, and the CHANGELOG notes it.

Tests

New file tests/test_lease_issuance.py (9 tests: forged lease, a lease from another instance, a retargeted lease, edited expires_at, a cleared revoked flag, keepalive on an unissued lease, keepalive still renewing an issued one, is_live() on an unissued lease, and the export).

On main (cc6b812), with only the new test file copied in:

E       Failed: DID NOT RAISE InterlockError      (forged lease permitted)
E       Failed: DID NOT RAISE InterlockError      (lease from another Interlock permitted)
E       Failed: DID NOT RAISE InterlockError      (retargeted lease permitted)
E       Failed: DID NOT RAISE LeaseExpired        (edited expires_at extended authority)
E       Failed: DID NOT RAISE LeaseRevoked        (clearing revoked restored authority)
E       Failed: DID NOT RAISE InterlockError      (keepalive renewed a forged lease)
E       AssertionError: assert True is False      (is_live true for a forged lease)
E       assert (None is not None)                 (LeaseNotIssued does not exist)
8 failed, 1 passed in 0.04s                       (the passing one is the backward-compat keepalive test)

With this fix:

$ pytest -q tests/test_lease_issuance.py
9 passed
$ pytest -q
68 passed
  • pytest -q passes (68; it was 59)
  • ruff check src tests and ruff format --check src tests pass
  • Zero runtime dependencies

CHANGELOG (Unreleased → Fixed and Added), plus the README guarantee list, quickstart comment, how-it-fails table and test count, docs/guides/quickstart.md, docs/concepts/leases.md, and the matching line in CHARTER.md are updated.

Interlock.permit() trusted whatever Lease object it was handed, so a
hand-built Lease with a far-future expires_at passed the gate for any
resource. LeaseManager now keeps its own record of every lease it grants,
and that record decides expiry and revocation. permit() raises the new
LeaseNotIssued for leases it did not issue or whose identity was edited,
and logs the denial with reason lease_not_issued.
@FetchDoge
FetchDoge merged commit b351fa8 into main Oct 7, 2026
7 checks passed
@FetchDoge
FetchDoge deleted the fix/reject-unissued-leases branch October 7, 2026 21:13
FetchDoge added a commit that referenced this pull request Oct 8, 2026
Resolve the quickstart conflict with #7: keep LeaseNotIssued in the gate
order, keep the admission-only caveat, import the errors from the public
interlock package, and use a 3.11+ python3 for the venv. Docs only.
FetchDoge added a commit that referenced this pull request Oct 8, 2026
Resolve README and CHANGELOG conflicts with #4-#7: keep main's restructured
README and add the demonstrator line in house style; fold the demonstrator
entry into the Unreleased Added section. Re-ran scripts/demo_90s.py on the
merged tree: exit 0 on 6 runs, output identical to demo/TRANSCRIPT.md except
the random lease id, so the transcript is unchanged.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant