Repository navigation
One lease is read directly, and gateway leases name the worker's lease id #913
Description
Part of #905.
Scope
GET /v1/leases/{id} and GET /v1/leases/{id}/events find one lease directly instead of building the whole lease list, and on a gateway every lease in GET /v1/leases names the worker's own lease id, so a relayed event can be matched to it (#916).
In short
Reading one lease no longer walks every lease of the fleet. On a gateway, each lease record gains workerLeaseId, e.g. { "id": "w1.lse_42", "workerId": "w1", "workerLeaseId": "lse_42", … }. Nothing else a user sees changes: the GET /v1/leases/{id} reply keeps its shape (no workerLeaseId), status.get keeps its shape, and an unknown or not-owned lease still answers 404 UNKNOWN_LEASE.
flowchart LR
H[GET /v1/leases/id] --> F["findOwnedLease"] --> G["lease.get (new)"]
L[GET /v1/leases] --> P["lease.list + workerLeaseId (new)"]
If it goes wrong: an agent reads a lease that is not theirs, or a lease answers 404 while it is live.
Technical spec
Facts
| # | Fact | Source |
|---|---|---|
| F1 | findOwnedLease dispatches lease.list and .finds the id; not found → unknownLease (404). There is no lease.get. |
src/http/app.ts:783-801 |
| F2 | GET /v1/leases/{id} uses findOwnedLease then findDevice; /events uses findOwnedLease too. |
src/http/app.ts:371-376; src/http/AGENTS.md Known gaps |
| F3 | Worker lease.list: registry snapshot leases, own only unless admin. |
src/daemon/dispatcher.ts:627-632 |
| F4 | Gateway lease.list: admin → every view's leases through leaseIndex.project; else each index entry owned by the caller, its raw lease found in the view by entry.workerLeaseId. |
src/gateway/dispatcher.ts:517-537 |
| F5 | project replaces id with the gateway id when the index knows (workerId, lease.id), else keeps the worker's id; it adds workerId in both cases. The raw worker id is not kept. |
src/gateway/lease-index.ts:330-350 |
| F6 | lease.list output is { leases: statusLeaseSchema[] }; statusLeaseSchema = leaseRecordSchema + optional workerId. |
src/contract/operations.ts:530-535; src/contract/schemas.ts:1383-1386 |
| F7 | defineOperation declares name, role, effect, input, output, authorize. |
src/contract/operations.ts:60-90 |
| F8 | A contract shape change bumps the protocol in the same PR, checked by protocol-bump-check.mjs. |
docs/internal/agent-rules/toolchain.md:196-203 |
| F10 | project has three callers: gateway lease.list (owned, dispatcher.ts:534), the admin fleet list #fleetLeases used by lease.list and list.get kind=leases (dispatcher.ts:590), and status.get's aggregate (aggregate.ts:89). |
grep -rn "\.project(" src |
| F11 | Today an admin's GET /v1/leases/{id} of a worker's own local lease (one the index does not know) answers 200: the admin list holds it under its raw id. |
src/gateway/dispatcher.ts:585-592; src/gateway/lease-index.ts:332-350 |
| F12 | Every operation but daemon.stop has a case in DaemonServer's socket switch. |
src/daemon/server.ts:1000-1218 |
| F9 | remote-agents.mdx lists GET /v1/leases ("Your leases.") and GET /v1/leases/<id>. |
website/src/content/docs/docs/using-devices/remote-agents.mdx:166-168 |
Modules touched
src/contract/operations.ts, src/contract/schemas.ts (workerLeaseId on statusLeaseSchema), src/contract/protocol.ts (bump), src/daemon/dispatcher.ts and src/gateway/dispatcher.ts (lease.get; gateway lease.list adds workerLeaseId), src/daemon/server.ts (socket case for lease.get), src/http/app.ts (findOwnedLease dispatches lease.get), src/http/AGENTS.md, src/gateway/AGENTS.md, src/contract/AGENTS.md, remote-agents.mdx (field in the lease list). project itself does not change: the gateway's two fleet lease builders (F10, dispatcher.ts:534, 590) add workerLeaseId: raw.id after projecting, so lease.list and list.get kind=leases carry it and status.get does not. Searches: findOwnedLease (3 hits), lease.list in src/ (dispatchers, http, simlock-client), \.project( (3 hits, F10).
sequenceDiagram
participant C as Client
participant H as HTTP app
participant D as Dispatcher (worker or gateway)
C->>H: GET /v1/leases/w1.lse_42
H->>D: lease.get {leaseId}
alt found and owned (or admin)
D-->>H: {lease}
H-->>C: 200 (shape as today)
else unknown or not owned
D-->>H: UNKNOWN_LEASE
H-->>C: 404 UNKNOWN_LEASE
end
Contract and event changes
- New op
lease.get, roleagent, effectread, input{ leaseId: string }(unbounded, aslease.renew'sleaseId,src/contract/operations.ts:376; an unknown string answers 404), output{ lease: statusLeaseSchema }, noauthorize: the handler answersUNKNOWN_LEASEfor a lease the caller does not own, aslease.listhides it today (F1). statusLeaseSchemagains optionalworkerLeaseId: string, set only by a gateway'slease.list,lease.getandlist.get kind=leases;status.getnever sets it.- Gateway
lease.getfinds the recordlease.listwould hold for this caller with thatid: an index entry → its view's lease (owner checked unless admin); otherwise, for an admin only, a view lease with that raw id that the index does not rename (F11). A raw id the index renames (lse_42known asw1.lse_42) answers 404, as today. - The socket serves
lease.gettoo (F12). - This PR bumps the protocol by one. No event changes.
Other code on the same state
The gateway lease index: #ownedFleetLeases (F4) and findByWorkerLease read it; lease.get reads, never writes it. A lease released between lookup and reply answers as at that moment, as lease.list does. Searches: leaseIndex. in src/gateway/ (12 hits).
Failure modes
| Call | Outcome | What happens | Limit, counted from |
|---|---|---|---|
lease.get |
found | 200 | none |
lease.get |
unknown / not owned | 404 UNKNOWN_LEASE |
none |
lease.get (gateway) |
index entry, view missing or no longer listing the lease | 404 UNKNOWN_LEASE, as #ownedFleetLeases skips it |
none |
lease.get (gateway) |
worker's own local lease, admin | 200 (F11) | none |
No state is written.
Rules in play
- ADR 0044 §3 (
lease.get,workerLeaseId): complies. - ADR 0005 §16 "a gateway lease id is
<workerId>.<workerLeaseId>":workerLeaseIdnames that part, also for requester-chosen ids (ADR 0020). - Toolchain "every contract-shape change bumps it": bumps.
Tests
Seams: the handlers through src/daemon/dispatcher.test.ts (as its lease.list test, :1159) and src/gateway/dispatcher.test.ts; the HTTP mapping through src/http/app.test.ts with its fake dispatcher.
- A client reads one lease (
lease.geton each dispatcher, andGET /v1/leases/{id}in the app): own → found, reply in today's shape; another agent's →UNKNOWN_LEASE/ 404; admin any → found; unknown id → 404; gateway by gateway id → found withworkerLeaseId; gateway admin, a worker's own local lease → found; gateway, a raw id the index renames → 404; gateway, an index entry whose worker view is missing or no longer lists the lease → 404 (decided in delivery, accepted by the maintainer 2026-10-23);/eventsof an unknown id → 404; over the daemon socket → found (as the socket tests ofsrc/daemon/server.test.ts). - An admin lists leases on a gateway: gateway-minted id →
workerLeaseIdis the worker's id; requester-chosen id →workerLeaseIdequals it; a lease the index does not know →workerLeaseIdequalsid;list.get kind=leases→ same;status.get→ noworkerLeaseId; a worker's own list → noworkerLeaseId.
Done when
pnpm vitest run src/http/app.test.ts src/gateway/dispatcher.test.ts src/daemon/dispatcher.test.ts src/gateway/aggregate.test.tspasses.- The protocol bump check passes.
Out of scope
403 instead of 404 for a not-owned lease. findDevice's device scan.
Depends on
Written by an agent.
Activity
[-]Unchanged JSON answers under /v1 are 304 with an ETag[/-][+]One lease is read directly, and gateway leases name the worker's lease id[/+]test: a client reads one lease through lease.get on the gateway dispa…
feat(gateway): the gateway dispatcher answers lease.get (#913)
test: a client reads one lease through GET /v1/leases/{id} and its ev…
feat(http): GET /v1/leases/{id} and its events find one lease with le…
test: a client reads one lease over the daemon socket (#913)
feat(daemon): the socket serves lease.get (#913)
test: a gateway's lease lists name the worker's own lease id as worke…
feat(gateway): a gateway's lease lists carry workerLeaseId (#913)
docs: lease.get and workerLeaseId in the module manuals and remote ag…
refactor(gateway): split lease.get's lookup by where the lease lives (#…
docs: fix comments and a test title that still named lease.list as th…
test(gateway): lease.get answers UNKNOWN_LEASE for an index entry who…
docs: protocol range in the manuals is 58, drop a comment naming remo…
fix(ci): the daemon protocol test expects version 58, the gateway man…
feat(gateway): read one lease directly, name the worker's lease id (#913